pull/330/head
vapao 2021-03-22 23:18:03 +08:00
parent 11bf8d1f7d
commit 54cb817fc4
14 changed files with 444 additions and 359 deletions

View File

@ -8,6 +8,7 @@ from .views import *
urlpatterns = [
path('request/', RequestView.as_view()),
path('request/1/', post_request_1),
path('request/2/', post_request_2),
path('request/upload/', do_upload),
path('request/<int:r_id>/', RequestDetailView.as_view()),
]

View File

@ -29,7 +29,6 @@ def dispatch(req):
api_token = uuid.uuid4().hex
rds.setex(api_token, 60 * 60, f'{req.deploy.app_id},{req.deploy.env_id}')
helper = Helper(rds, rds_key)
# helper.send_step('local', 1, f'完成\r\n{human_time()} 发布准备... ')
env = AttrDict(
SPUG_APP_NAME=req.deploy.app.name,
SPUG_APP_ID=str(req.deploy.app_id),
@ -80,18 +79,16 @@ def _ext1_deploy(req, helper, env):
def _ext2_deploy(req, helper, env):
extend = req.deploy.extend_obj
extras = json.loads(req.extra)
helper.send_info('local', f'完成\r\n')
extend, step = req.deploy.extend_obj, 1
host_actions = json.loads(extend.host_actions)
server_actions = json.loads(extend.server_actions)
if extras and extras[0]:
env.update({'SPUG_RELEASE': extras[0]})
step = 2
env.update({'SPUG_RELEASE': req.version})
for action in server_actions:
helper.send_step('local', step, f'\r\n{human_time()} {action["title"]}...\r\n')
helper.send_step('local', step, f'{human_time()} {action["title"]}...\r\n')
helper.local(f'cd /tmp && {action["data"]}', env)
step += 1
helper.send_step('local', 100, '完成\r\n' if step == 2 else '\r\n')
helper.send_step('local', 100, '\r\n')
tmp_transfer_file = None
for action in host_actions:
@ -119,17 +116,18 @@ def _ext2_deploy(req, helper, env):
else:
excludes.append(f'--exclude={x}')
exclude = ' '.join(excludes)
tar_gz_file = f'{env.SPUG_VERSION}.tar.gz'
tar_gz_file = f'{env.spug_version}.tar.gz'
helper.local(f'cd {sp_dir} && tar zcf {tar_gz_file} {exclude} {contain}')
helper.send_info('local', '完成\r\n')
tmp_transfer_file = os.path.join(sp_dir, tar_gz_file)
break
if host_actions:
threads, latest_exception = [], None
with futures.ThreadPoolExecutor(max_workers=min(10, os.cpu_count() + 5)) as executor:
max_workers = min(10, os.cpu_count() * 4) if req.deploy.is_parallel else 1
with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for h_id in json.loads(req.host_ids):
env = AttrDict(env.items())
t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, env)
t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, env, req.spug_version)
t.h_id = h_id
threads.append(t)
for t in futures.as_completed(threads):
@ -190,23 +188,22 @@ def _deploy_ext1_host(req, helper, h_id, env):
command = f'cd {extend.dst_dir} ; {extend.hook_post_host}'
helper.remote(host.id, ssh, command, env)
helper.send_step(h_id, 5, f'\r\n{human_time()} ** 发布成功 **')
helper.send_step(h_id, 100, f'\r\n{human_time()} ** 发布成功 **')
def _deploy_ext2_host(helper, h_id, actions, env):
helper.send_step(h_id, 1, f'{human_time()} 数据准备... ')
def _deploy_ext2_host(helper, h_id, actions, env, spug_version):
helper.send_info(h_id, '就绪\r\n')
host = Host.objects.filter(pk=h_id).first()
if not host:
helper.send_error(h_id, 'no such host')
env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname})
ssh = host.get_ssh()
helper.send_step(h_id, 2, '完成\r\n')
for index, action in enumerate(actions):
helper.send_step(h_id, 2 + index, f'{human_time()} {action["title"]}...\r\n')
helper.send_step(h_id, 1 + index, f'{human_time()} {action["title"]}...\r\n')
if action.get('type') == 'transfer':
if action.get('src_mode') == '1':
try:
ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, env.SPUG_VERSION), action['dst'])
ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, spug_version), action['dst'])
except Exception as e:
helper.send_error(host.id, f'exception: {e}')
helper.send_info(host.id, 'transfer completed\r\n')

View File

@ -43,6 +43,7 @@ class RequestView(View):
tmp['app_name'] = item.app_name
tmp['app_extend'] = item.app_extend
tmp['host_ids'] = json.loads(item.host_ids)
tmp['extra'] = json.loads(item.extra) if item.extra else None
tmp['rep_extra'] = json.loads(item.rep_extra) if item.rep_extra else None
tmp['app_host_ids'] = json.loads(item.app_host_ids)
tmp['status_alias'] = item.get_status_display()
@ -50,45 +51,6 @@ class RequestView(View):
data.append(tmp)
return json_response(data)
def post(self, request):
form, error = JsonParser(
Argument('id', type=int, required=False),
Argument('deploy_id', type=int, help='缺少必要参数'),
Argument('name', help='请输申请标题'),
Argument('extra', type=list, help='缺少必要参数'),
Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'),
Argument('desc', required=False),
).parse(request.body)
if error is None:
deploy = Deploy.objects.filter(pk=form.deploy_id).first()
if not deploy:
return json_response(error='未找到该发布配置')
if deploy.extend == '2':
if form.extra[0]:
form.extra[0] = form.extra[0].replace("'", '')
if DeployExtend2.objects.filter(deploy=deploy, host_actions__contains='"src_mode": "1"').exists():
if len(form.extra) < 2:
return json_response(error='该应用的发布配置中使用了数据传输动作且设置为发布时上传,请上传要传输的数据')
form.version = form.extra[1].get('path')
form.name = form.name.replace("'", '')
form.status = '0' if deploy.is_audit else '1'
form.extra = json.dumps(form.extra)
form.host_ids = json.dumps(form.host_ids)
if form.id:
req = DeployRequest.objects.get(pk=form.id)
is_required_notify = deploy.is_audit and req.status == '-1'
DeployRequest.objects.filter(pk=form.id).update(
created_by=request.user,
reason=None,
**form
)
else:
req = DeployRequest.objects.create(created_by=request.user, **form)
is_required_notify = deploy.is_audit
if is_required_notify:
Thread(target=Helper.send_deploy_notify, args=(req, 'approve_req')).start()
return json_response(error=error)
def put(self, request):
form, error = JsonParser(
Argument('id', type=int, help='缺少必要参数'),
@ -162,11 +124,12 @@ class RequestDetailView(View):
if not req:
return json_response(error='未找到指定发布申请')
hosts = Host.objects.filter(id__in=json.loads(req.host_ids))
server_actions, host_actions = [], []
outputs = {x.id: {'id': x.id, 'title': x.name, 'data': []} for x in hosts}
response = {'outputs': outputs, 'status': req.status}
if req.deploy.extend == '2':
server_actions = json.loads(req.deploy.extend_obj.server_actions)
host_actions = json.loads(req.deploy.extend_obj.host_actions)
outputs['local'] = {'id': 'local', 'data': []}
response['s_actions'] = json.loads(req.deploy.extend_obj.server_actions)
response['h_actions'] = json.loads(req.deploy.extend_obj.host_actions)
rds, key, counter = get_redis_connection(), f'{settings.REQUEST_KEY}:{r_id}', 0
data = rds.lrange(key, counter, counter + 9)
while data:
@ -180,12 +143,7 @@ class RequestDetailView(View):
if 'status' in item:
outputs[item['key']]['status'] = item['status']
data = rds.lrange(key, counter, counter + 9)
return json_response({
'server_actions': server_actions,
'host_actions': host_actions,
'outputs': outputs,
'status': req.status
})
return json_response(response)
def post(self, request, r_id):
query = {'pk': r_id}
@ -206,7 +164,13 @@ class RequestDetailView(View):
req.do_by = request.user
req.save()
Thread(target=dispatch, args=(req,)).start()
return json_response({'type': req.type, 'outputs': outputs})
if req.deploy.extend == '2':
message = f'{human_time()} 建立连接... '
outputs['local'] = {'id': 'local', 'step': 0, 'data': [message]}
s_actions = json.loads(req.deploy.extend_obj.server_actions)
h_actions = json.loads(req.deploy.extend_obj.host_actions)
return json_response({'s_actions': s_actions, 'h_actions': h_actions, 'outputs': outputs})
return json_response({'outputs': outputs})
def patch(self, request, r_id):
form, error = JsonParser(
@ -261,6 +225,43 @@ def post_request_1(request):
return json_response(error=error)
def post_request_2(request):
form, error = JsonParser(
Argument('id', type=int, required=False),
Argument('deploy_id', type=int, help='缺少必要参数'),
Argument('name', help='请输申请标题'),
Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'),
Argument('extra', type=dict, required=False),
Argument('version', required=False),
Argument('desc', required=False),
).parse(request.body)
if error is None:
deploy = Deploy.objects.filter(pk=form.deploy_id).first()
if not deploy:
return json_response(error='未找到该发布配置')
extra = form.pop('extra')
if DeployExtend2.objects.filter(deploy=deploy, host_actions__contains='"src_mode": "1"').exists():
if not extra:
return json_response(error='该应用的发布配置中使用了数据传输动作且设置为发布时上传,请上传要传输的数据')
form.spug_version = extra['path']
form.extra = json.dumps(extra)
else:
form.spug_version = Repository.make_spug_version(deploy.id)
form.name = form.name.replace("'", '')
form.status = '0' if deploy.is_audit else '1'
form.host_ids = json.dumps(form.host_ids)
if form.id:
req = DeployRequest.objects.get(pk=form.id)
is_required_notify = deploy.is_audit and req.status == '-1'
DeployRequest.objects.filter(pk=form.id).update(created_by=request.user, reason=None, **form)
else:
req = DeployRequest.objects.create(created_by=request.user, **form)
is_required_notify = deploy.is_audit
if is_required_notify:
Thread(target=Helper.send_deploy_notify, args=(req, 'approve_req')).start()
return json_response(error=error)
def do_upload(request):
repos_dir = settings.REPOS_DIR
file = request.FILES['file']

View File

@ -9,7 +9,6 @@ import { Modal, Steps } from 'antd';
import styles from './index.module.css';
import Setup1 from './Ext2Setup1';
import Setup2 from './Ext2Setup2';
import Setup3 from './Ext2Setup3';
import store from './store';
export default observer(function Ext2From() {
@ -30,12 +29,10 @@ export default observer(function Ext2From() {
footer={null}>
<Steps current={store.page} className={styles.steps}>
<Steps.Step key={0} title="基本配置"/>
<Steps.Step key={1} title="发布主机"/>
<Steps.Step key={2} title="执行动作"/>
<Steps.Step key={1} title="执行动作"/>
</Steps>
{store.page === 0 && <Setup1/>}
{store.page === 1 && <Setup2/>}
{store.page === 2 && <Setup3/>}
</Modal>
)
})

View File

@ -6,12 +6,14 @@
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react';
import { Link } from 'react-router-dom';
import { Form, Switch, Select, Button, Input, Radio } from "antd";
import { Form, Switch, Select, Button, Input, Radio } from 'antd';
import envStore from 'pages/config/environment/store';
import Selector from 'pages/host/Selector';
import store from './store';
export default observer(function Ext2Setup1() {
const [envs, setEnvs] = useState([]);
const [selectorVisible, setSelectorVisible] = useState(false);
function updateEnvs() {
const ids = store.currentRecord['deploys'].map(x => x.env_id);
@ -41,6 +43,10 @@ export default observer(function Ext2Setup1() {
<Link disabled={store.isReadOnly} to="/config/environment">新建环境</Link>
</Form.Item>
</Form.Item>
<Form.Item required label="目标主机">
{info.host_ids.length > 0 && `已选择 ${info.host_ids.length}`}
<Button type="link" onClick={() => setSelectorVisible(true)}>选择主机</Button>
</Form.Item>
<Form.Item label="发布模式">
<Radio.Group buttonStyle="solid" value={info.is_parallel} onChange={e => info.is_parallel = e.target.value}>
<Radio.Button value={true}>并行</Radio.Button>
@ -82,6 +88,11 @@ export default observer(function Ext2Setup1() {
disabled={!info.env_id}
onClick={() => store.page += 1}>下一步</Button>
</Form.Item>
<Selector
visible={selectorVisible}
selectedRowKeys={[...info.host_ids]}
onCancel={() => setSelectorVisible(false)}
onOk={(_, ids) => info.host_ids = ids}/>
</Form>
)
})

View File

@ -6,56 +6,186 @@
import React from 'react';
import { observer } from 'mobx-react';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Form, Select, Button } from 'antd';
import { hasHostPermission } from 'libs';
import store from './store';
import hostStore from 'pages/host/store';
import { Form, Input, Button, message, Divider, Alert, Select } from 'antd';
import Editor from 'react-ace';
import 'ace-builds/src-noconflict/mode-sh';
import 'ace-builds/src-noconflict/theme-tomorrow';
import styles from './index.module.css';
import { http, cleanCommand } from 'libs';
import store from './store';
import lds from 'lodash';
@observer
class Ext2Setup2 extends React.Component {
componentDidMount() {
if (hostStore.records.length === 0) {
hostStore.fetchRecords()
constructor(props) {
super(props);
this.helpMap = {
'0': null,
'1': '相对于输入的本地路径的文件路径,仅将匹配到文件传输至要发布的目标主机。',
'2': '支持模糊匹配,如果路径以 / 开头则基于输入的本地路径匹配,匹配到文件将不会被传输。'
}
this.state = {
loading: false,
}
}
render() {
handleSubmit = () => {
this.setState({loading: true});
const info = store.deploy;
info['app_id'] = store.app_id;
info['extend'] = '2';
info['host_actions'] = info['host_actions'].filter(x => (x.title && x.data) || (x.title && (x.src || x.src_mode === '1') && x.dst));
info['server_actions'] = info['server_actions'].filter(x => x.title && x.data);
http.post('/api/app/deploy/', info)
.then(res => {
message.success('保存成功');
store.ext2Visible = false;
store.loadDeploys(store.app_id)
}, () => this.setState({loading: false}))
};
render() {
const server_actions = store.deploy['server_actions'];
const host_actions = store.deploy['host_actions'];
return (
<Form labelCol={{span: 6}} wrapperCol={{span: 14}}>
<Form.Item required label="发布目标主机">
{info['host_ids'].map((id, index) => (
<React.Fragment key={index}>
<Select
value={id}
showSearch
disabled={store.isReadOnly}
placeholder="请选择"
optionFilterProp="children"
style={{width: '80%', marginRight: 10, marginBottom: 12}}
filterOption={(input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0}
onChange={v => store.editHost(index, v)}>
{hostStore.records.filter(x => hasHostPermission(x.id)).map(item => (
<Select.Option key={item.id} value={item.id} disabled={info['host_ids'].includes(item.id)}>
{`${item.name}(${item['hostname']}:${item['port']})`}
</Select.Option>
))}
</Select>
{!store.isReadOnly && info['host_ids'].length > 1 && (
<MinusCircleOutlined className={styles.delIcon} onClick={() => store.delHost(index)} />
)}
</React.Fragment>
))}
</Form.Item>
<Form labelCol={{span: 6}} wrapperCol={{span: 14}} className={styles.ext2Form}>
{store.deploy.id === undefined && (
<Alert
closable
showIcon
type="info"
message="小提示"
style={{margin: '0 80px 20px'}}
description={[
<p key={1}>Spug 将遵循先本地后目标主机的原则按照顺序依次执行添加的动作例如本地动作1 -> 本地动作2 -> 目标主机动作1 -> 目标主机动作2 ...</p>,
<p key={2}>执行的命令内可以使用发布申请中设置的环境变量 SPUG_RELEASE一般可用于标记一次发布的版本号或提交ID等在执行的脚本内通过使用 $SPUG_RELEASE
获取其值来执行相应操作</p>
]}/>
)}
{server_actions.map((item, index) => (
<div key={index} style={{marginBottom: 30, position: 'relative'}}>
<Form.Item required label={`本地动作${index + 1}`}>
<Input disabled={store.isReadOnly} value={item['title']} onChange={e => item['title'] = e.target.value}
placeholder="请输入"/>
</Form.Item>
<Form.Item required label="执行内容">
<Editor
readOnly={store.isReadOnly}
mode="sh"
theme="tomorrow"
width="100%"
height="100px"
value={item['data']}
onChange={v => item['data'] = cleanCommand(v)}
placeholder="请输入要执行的动作"/>
</Form.Item>
{!store.isReadOnly && (
<div className={styles.delAction} onClick={() => server_actions.splice(index, 1)}>
<MinusCircleOutlined />移除
</div>
)}
</div>
))}
{!store.isReadOnly && (
<Form.Item wrapperCol={{span: 14, offset: 6}}>
<Button type="dashed" block onClick={() => server_actions.push({})}>
<PlusOutlined />添加本地执行动作在服务端本地执行
</Button>
</Form.Item>
)}
<Divider/>
{host_actions.map((item, index) => (
<div key={index} style={{marginBottom: 30, position: 'relative'}}>
<Form.Item required label={`目标主机动作${index + 1}`}>
<Input disabled={store.isReadOnly} value={item['title']} onChange={e => item['title'] = e.target.value}
placeholder="请输入"/>
</Form.Item>
{item['type'] === 'transfer' ? ([
<Form.Item key={0} required label="数据来源">
<Input
spellCheck={false}
disabled={store.isReadOnly || item['src_mode'] === '1'}
placeholder="请输入本地部署spug的容器或主机路径"
value={item['src']}
onChange={e => item['src'] = e.target.value}
addonBefore={(
<Select disabled={store.isReadOnly} style={{width: 120}} value={item['src_mode'] || '0'}
onChange={v => item['src_mode'] = v}>
<Select.Option value="0">本地路径</Select.Option>
<Select.Option value="1">发布时上传</Select.Option>
</Select>
)}/>
</Form.Item>,
[undefined, '0'].includes(item['src_mode']) ? (
<Form.Item key={1} label="过滤规则" help={this.helpMap[item['mode']]}>
<Input
spellCheck={false}
placeholder="请输入逗号分割的过滤规则"
value={item['rule']}
onChange={e => item['rule'] = e.target.value.replace('', ',')}
disabled={store.isReadOnly || item['mode'] === '0'}
addonBefore={(
<Select disabled={store.isReadOnly} style={{width: 120}} value={item['mode']}
onChange={v => item['mode'] = v}>
<Select.Option value="0">关闭</Select.Option>
<Select.Option value="1">包含</Select.Option>
<Select.Option value="2">排除</Select.Option>
</Select>
)}/>
</Form.Item>
) : null,
<Form.Item key={2} required label="目标路径" extra={<a
target="_blank" rel="noopener noreferrer"
href="https://spug.dev/docs/deploy-config#%E6%95%B0%E6%8D%AE%E4%BC%A0%E8%BE%93">使用前请务必阅读官方文档</a>}>
<Input
disabled={store.isReadOnly}
spellCheck={false}
value={item['dst']}
placeholder="请输入目标主机路径"
onChange={e => item['dst'] = e.target.value}/>
</Form.Item>
]) : (
<Form.Item required label="执行内容">
<Editor
readOnly={store.isReadOnly}
mode="sh"
theme="tomorrow"
width="100%"
height="100px"
value={item['data']}
onChange={v => item['data'] = cleanCommand(v)}
placeholder="请输入要执行的动作"/>
</Form.Item>
)}
{!store.isReadOnly && (
<div className={styles.delAction} onClick={() => host_actions.splice(index, 1)}>
<MinusCircleOutlined />移除
</div>
)}
</div>
))}
{!store.isReadOnly && (
<Form.Item wrapperCol={{span: 14, offset: 6}}>
<Button disabled={store.isReadOnly} type="dashed" block onClick={() => host_actions.push({})}>
<PlusOutlined />添加目标主机执行动作在部署目标主机执行
</Button>
<Button
block
type="dashed"
style={{marginTop: 8}}
disabled={store.isReadOnly || lds.findIndex(host_actions, x => x.type === 'transfer') !== -1}
onClick={() => host_actions.push({type: 'transfer', title: '数据传输', mode: '0', src_mode: '0'})}>
<PlusOutlined />添加数据传输动作仅能添加一个
</Button>
</Form.Item>
)}
<Form.Item wrapperCol={{span: 14, offset: 6}}>
<Button disabled={store.isReadOnly} type="dashed" style={{width: '80%'}} onClick={store.addHost}>
<PlusOutlined />添加目标主机
</Button>
</Form.Item>
<Form.Item wrapperCol={{span: 14, offset: 6}}>
<Button disabled={info['host_ids'].filter(x => x).length === 0} type="primary"
onClick={() => store.page += 1}>下一步</Button>
<Button
type="primary"
disabled={store.isReadOnly || [...host_actions, ...server_actions].filter(x => x.title && x.data).length === 0}
loading={this.state.loading}
onClick={this.handleSubmit}>提交</Button>
<Button style={{marginLeft: 20}} onClick={() => store.page -= 1}>上一步</Button>
</Form.Item>
</Form>

View File

@ -1,196 +0,0 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React from 'react';
import { observer } from 'mobx-react';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Form, Input, Button, message, Divider, Alert, Select } from 'antd';
import Editor from 'react-ace';
import 'ace-builds/src-noconflict/mode-sh';
import 'ace-builds/src-noconflict/theme-tomorrow';
import styles from './index.module.css';
import { http, cleanCommand } from 'libs';
import store from './store';
import lds from 'lodash';
@observer
class Ext2Setup3 extends React.Component {
constructor(props) {
super(props);
this.helpMap = {
'0': null,
'1': '相对于输入的本地路径的文件路径,仅将匹配到文件传输至要发布的目标主机。',
'2': '支持模糊匹配,如果路径以 / 开头则基于输入的本地路径匹配,匹配到文件将不会被传输。'
}
this.state = {
loading: false,
}
}
handleSubmit = () => {
this.setState({loading: true});
const info = store.deploy;
info['app_id'] = store.app_id;
info['extend'] = '2';
info['host_actions'] = info['host_actions'].filter(x => (x.title && x.data) || (x.title && (x.src || x.src_mode === '1') && x.dst));
info['server_actions'] = info['server_actions'].filter(x => x.title && x.data);
http.post('/api/app/deploy/', info)
.then(res => {
message.success('保存成功');
store.ext2Visible = false;
store.loadDeploys(store.app_id)
}, () => this.setState({loading: false}))
};
render() {
const server_actions = store.deploy['server_actions'];
const host_actions = store.deploy['host_actions'];
return (
<Form labelCol={{span: 6}} wrapperCol={{span: 14}} className={styles.ext2Form}>
{store.deploy.id === undefined && (
<Alert
closable
showIcon
type="info"
message="小提示"
style={{margin: '0 80px 20px'}}
description={[
<p key={1}>Spug 将遵循先本地后目标主机的原则按照顺序依次执行添加的动作例如本地动作1 -> 本地动作2 -> 目标主机动作1 -> 目标主机动作2 ...</p>,
<p key={2}>执行的命令内可以使用发布申请中设置的环境变量 SPUG_RELEASE一般可用于标记一次发布的版本号或提交ID等在执行的脚本内通过使用 $SPUG_RELEASE
获取其值来执行相应操作</p>
]}/>
)}
{server_actions.map((item, index) => (
<div key={index} style={{marginBottom: 30, position: 'relative'}}>
<Form.Item required label={`本地动作${index + 1}`}>
<Input disabled={store.isReadOnly} value={item['title']} onChange={e => item['title'] = e.target.value}
placeholder="请输入"/>
</Form.Item>
<Form.Item required label="执行内容">
<Editor
readOnly={store.isReadOnly}
mode="sh"
theme="tomorrow"
width="100%"
height="100px"
value={item['data']}
onChange={v => item['data'] = cleanCommand(v)}
placeholder="请输入要执行的动作"/>
</Form.Item>
{!store.isReadOnly && (
<div className={styles.delAction} onClick={() => server_actions.splice(index, 1)}>
<MinusCircleOutlined />移除
</div>
)}
</div>
))}
{!store.isReadOnly && (
<Form.Item wrapperCol={{span: 14, offset: 6}}>
<Button type="dashed" block onClick={() => server_actions.push({})}>
<PlusOutlined />添加本地执行动作在服务端本地执行
</Button>
</Form.Item>
)}
<Divider/>
{host_actions.map((item, index) => (
<div key={index} style={{marginBottom: 30, position: 'relative'}}>
<Form.Item required label={`目标主机动作${index + 1}`}>
<Input disabled={store.isReadOnly} value={item['title']} onChange={e => item['title'] = e.target.value}
placeholder="请输入"/>
</Form.Item>
{item['type'] === 'transfer' ? ([
<Form.Item key={0} required label="数据来源">
<Input
spellCheck={false}
disabled={store.isReadOnly || item['src_mode'] === '1'}
placeholder="请输入本地部署spug的容器或主机路径"
value={item['src']}
onChange={e => item['src'] = e.target.value}
addonBefore={(
<Select disabled={store.isReadOnly} style={{width: 120}} value={item['src_mode'] || '0'}
onChange={v => item['src_mode'] = v}>
<Select.Option value="0">本地路径</Select.Option>
<Select.Option value="1">发布时上传</Select.Option>
</Select>
)}/>
</Form.Item>,
[undefined, '0'].includes(item['src_mode']) ? (
<Form.Item key={1} label="过滤规则" help={this.helpMap[item['mode']]}>
<Input
spellCheck={false}
placeholder="请输入逗号分割的过滤规则"
value={item['rule']}
onChange={e => item['rule'] = e.target.value.replace('', ',')}
disabled={store.isReadOnly || item['mode'] === '0'}
addonBefore={(
<Select disabled={store.isReadOnly} style={{width: 120}} value={item['mode']}
onChange={v => item['mode'] = v}>
<Select.Option value="0">关闭</Select.Option>
<Select.Option value="1">包含</Select.Option>
<Select.Option value="2">排除</Select.Option>
</Select>
)}/>
</Form.Item>
) : null,
<Form.Item key={2} required label="目标路径" extra={<a
target="_blank" rel="noopener noreferrer"
href="https://spug.dev/docs/deploy-config#%E6%95%B0%E6%8D%AE%E4%BC%A0%E8%BE%93">使用前请务必阅读官方文档</a>}>
<Input
disabled={store.isReadOnly}
spellCheck={false}
value={item['dst']}
placeholder="请输入目标主机路径"
onChange={e => item['dst'] = e.target.value}/>
</Form.Item>
]) : (
<Form.Item required label="执行内容">
<Editor
readOnly={store.isReadOnly}
mode="sh"
theme="tomorrow"
width="100%"
height="100px"
value={item['data']}
onChange={v => item['data'] = cleanCommand(v)}
placeholder="请输入要执行的动作"/>
</Form.Item>
)}
{!store.isReadOnly && (
<div className={styles.delAction} onClick={() => host_actions.splice(index, 1)}>
<MinusCircleOutlined />移除
</div>
)}
</div>
))}
{!store.isReadOnly && (
<Form.Item wrapperCol={{span: 14, offset: 6}}>
<Button disabled={store.isReadOnly} type="dashed" block onClick={() => host_actions.push({})}>
<PlusOutlined />添加目标主机执行动作在部署目标主机执行
</Button>
<Button
block
type="dashed"
style={{marginTop: 8}}
disabled={store.isReadOnly || lds.findIndex(host_actions, x => x.type === 'transfer') !== -1}
onClick={() => host_actions.push({type: 'transfer', title: '数据传输', mode: '0', src_mode: '0'})}>
<PlusOutlined />添加数据传输动作仅能添加一个
</Button>
</Form.Item>
)}
<Form.Item wrapperCol={{span: 14, offset: 6}}>
<Button
type="primary"
disabled={store.isReadOnly || [...host_actions, ...server_actions].filter(x => x.title && x.data).length === 0}
loading={this.state.loading}
onClick={this.handleSubmit}>提交</Button>
<Button style={{marginLeft: 20}} onClick={() => store.page -= 1}>上一步</Button>
</Form.Item>
</Form>
)
}
}
export default Ext2Setup3

View File

@ -85,7 +85,7 @@ function Ext1Console(props) {
<Progress
key={item.id}
percent={(item.step + 1) * 18}
status={item.status === 'error' ? 'exception' : 'active'}/>
status={item.step === 100 ? 'success' : item.status === 'error' ? 'exception' : 'active'}/>
))}
</Card>
) : (

View File

@ -72,8 +72,8 @@ export default observer(function () {
))}
</Select>
</Form.Item>
<Form.Item required label="目标主机" help="可以通过创建多个发布申请单,选择主机分批发布。">
{host_ids.length > 0 && `已选择 ${host_ids.length}`}
<Form.Item required label="目标主机" tooltip="可以通过创建多个发布申请单,选择主机分批发布。">
{host_ids.length > 0 && `已选择 ${host_ids.length}(可选${app_host_ids.length}`}
<Button type="link" onClick={() => setVisible(true)}>选择主机</Button>
</Form.Item>
<Form.Item name="desc" label="备注信息">

View File

@ -0,0 +1,153 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, { useEffect, useState } from 'react';
import { observer, useLocalStore } from 'mobx-react';
import { Card, Progress, Modal, Collapse, Steps } from 'antd';
import { ShrinkOutlined, CaretRightOutlined, LoadingOutlined, CloseOutlined } from '@ant-design/icons';
import OutView from './OutView';
import { http, X_TOKEN } from 'libs';
import styles from './index.module.less';
import store from './store';
function Ext2Console(props) {
const outputs = useLocalStore(() => ({local: {id: 'local'}}));
const [sActions, setSActions] = useState([]);
const [hActions, setHActions] = useState([]);
useEffect(props.request.mode === 'read' ? readDeploy : doDeploy, [])
function readDeploy() {
let socket;
http.get(`/api/deploy/request/${props.request.id}/`)
.then(res => {
setSActions(res.s_actions);
setHActions(res.h_actions);
Object.assign(outputs, res.outputs)
if (res.status === '2') {
socket = _makeSocket()
}
})
return () => socket && socket.close()
}
function doDeploy() {
let socket;
http.post(`/api/deploy/request/${props.request.id}/`)
.then(res => {
setSActions(res.s_actions);
setHActions(res.h_actions);
Object.assign(outputs, res.outputs)
socket = _makeSocket()
})
return () => socket && socket.close()
}
function _makeSocket() {
let index = 0;
const token = props.request.id;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/request/${token}/?x-token=${X_TOKEN}`);
socket.onopen = () => socket.send(String(index));
socket.onmessage = e => {
if (e.data === 'pong') {
socket.send(String(index))
} else {
index += 1;
const {key, data, step, status} = JSON.parse(e.data);
if (data !== undefined) outputs[key].data.push(data);
if (step !== undefined) outputs[key].step = step;
if (status !== undefined) outputs[key].status = status;
}
}
return socket
}
function StepItem(props) {
let icon = null;
if (props.step === props.item.step && props.item.status !== 'error') {
if (props.item.id === 'local' || outputs.local.step === 100) {
icon = <LoadingOutlined/>
}
}
return <Steps.Step {...props} icon={icon}/>
}
function switchMiniMode() {
const value = store.tabModes[props.request.id];
store.tabModes[props.request.id] = !value
}
return store.tabModes[props.request.id] ? (
<Card
className={styles.item}
bodyStyle={{padding: '8px 12px'}}
onClick={switchMiniMode}>
<div className={styles.header}>
<div className={styles.title}>{props.request.name}</div>
<CloseOutlined onClick={() => store.showConsole(props.request, true)}/>
</div>
<Progress percent={(outputs.local.step + 1) * (90 / (1 + sActions.length)).toFixed(0)}
status={outputs.local.step === 100 ? 'success' : outputs.local.status === 'error' ? 'exception' : 'active'}/>
{Object.values(outputs).filter(x => x.id !== 'local').map(item => (
<Progress
key={item.id}
percent={item.step * (90 / (hActions.length).toFixed(0))}
status={item.step === 100 ? 'success' : item.status === 'error' ? 'exception' : 'active'}/>
))}
</Card>
) : (
<Modal
visible
width={1000}
footer={null}
maskClosable={false}
className={styles.console}
onCancel={() => store.showConsole(props.request, true)}
title={[
<span key="1">{props.request.name}</span>,
<div key="2" className={styles.miniIcon} onClick={switchMiniMode}>
<ShrinkOutlined/>
</div>
]}>
<Collapse defaultActiveKey="0" className={styles.collapse}>
<Collapse.Panel header={(
<Steps size="small" className={styles.step} current={outputs.local.step} status={outputs.local.status}>
<StepItem style={{width: 200}} title="建立连接" item={outputs.local} step={0}/>
{sActions.map((item, index) => (
<StepItem style={{width: 200}} key={index} title={item.title} item={outputs.local} step={index + 1}/>
))}
</Steps>
)}>
<OutView records={outputs.local.data}/>
</Collapse.Panel>
</Collapse>
<Collapse
defaultActiveKey="0"
className={styles.collapse}
style={{marginTop: 24}}
expandIcon={({isActive}) => <CaretRightOutlined style={{fontSize: 16}} rotate={isActive ? 90 : 0}/>}>
{Object.values(outputs).filter(x => x.id !== 'local').map((item, index) => (
<Collapse.Panel
key={index}
header={
<div className={styles.header}>
<b className={styles.title}>{item.title}{item.step}</b>
<Steps size="small" className={styles.step} current={item.step} status={item.status}>
<StepItem title="等待调度" item={item} step={0}/>
{hActions.map((action, index) => (
<StepItem key={index} title={action.title} item={item} step={index + 1}/>
))}
</Steps>
</div>}>
<OutView records={item.data}/>
</Collapse.Panel>
))}
</Collapse>
</Modal>
)
}
export default observer(Ext2Console)

View File

@ -8,10 +8,11 @@ import { observer } from 'mobx-react';
import { UploadOutlined } from '@ant-design/icons';
import { Modal, Form, Input, Upload, message, Button } from 'antd';
import hostStore from 'pages/host/store';
import HostSelector from './HostSelector';
import { http, X_TOKEN } from 'libs';
import styles from './index.module.less';
import store from './store';
import lds from 'lodash';
import HostSelector from "./HostSelector";
export default observer(function () {
const [form] = Form.useForm();
@ -19,17 +20,13 @@ export default observer(function () {
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [fileList, setFileList] = useState([]);
const [host_ids, setHostIds] = useState(lds.clone(store.record.app_host_ids));
const [host_ids, setHostIds] = useState([]);
useEffect(() => {
if (hostStore.records.length === 0) {
hostStore.fetchRecords()
}
const file = lds.get(store, 'record.extra.1');
if (file) {
file.uid = '0';
setFileList([file])
}
const {app_host_ids, host_ids, extra} = store.record;
setHostIds(lds.clone(host_ids || app_host_ids));
if (hostStore.records.length === 0) hostStore.fetchRecords();
if (store.record.extra) setFileList([{...extra, uid: '0'}])
}, [])
function handleSubmit() {
@ -39,13 +36,10 @@ export default observer(function () {
setLoading(true);
const formData = form.getFieldsValue();
formData['id'] = store.record.id;
formData['deploy_id'] = store.record.deploy_id;
formData['extra'] = [formData['extra']];
if (fileList.length > 0) {
formData['extra'].push(lds.pick(fileList[0], ['path', 'name']))
}
formData['host_ids'] = host_ids;
http.post('/api/deploy/request/', formData)
formData['deploy_id'] = store.record.deploy_id;
if (fileList.length > 0) formData['extra'] = lds.pick(fileList[0], ['path', 'name']);
http.post('/api/deploy/request/2/', formData)
.then(res => {
message.success('操作成功');
store.ext2Visible = false;
@ -73,6 +67,7 @@ export default observer(function () {
return false
}
const {app_host_ids, deploy_id} = store.record;
return (
<Modal
visible
@ -82,31 +77,33 @@ export default observer(function () {
onCancel={() => store.ext2Visible = false}
confirmLoading={loading}
onOk={handleSubmit}>
<Form form={form} labelCol={{span: 6}} wrapperCol={{span: 14}}>
<Form.Item required name="name" initialValue={store.record.name} label="申请标题">
<Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>
<Form.Item required name="name" label="申请标题">
<Input placeholder="请输入申请标题"/>
</Form.Item>
<Form.Item
name="extra"
initialValue={lds.get(store.record, 'extra.0')}
label="环境变量SPUG_RELEASE"
help="可以在自定义脚本中引用该变量,用于设置本次发布相关的动态变量,在脚本中通过 $SPUG_RELEASE 来使用该值。">
name="version"
label="SPUG_RELEASE"
tooltip="可以在自定义脚本中引用该变量,用于设置本次发布相关的动态变量,在脚本中通过 $SPUG_RELEASE 来使用该值。">
<Input placeholder="请输入环境变量 SPUG_RELEASE 的值"/>
</Form.Item>
<Form.Item label="上传数据" help="通过数据传输动作来使用上传的文件。">
<Form.Item label="上传数据" tooltip="通过数据传输动作来使用上传的文件。" className={styles.upload}>
<Upload name="file" fileList={fileList} headers={{'X-Token': X_TOKEN}} beforeUpload={handleUpload}
data={{deploy_id: store.record.deploy_id}} onChange={handleUploadChange}>
data={{deploy_id}} onChange={handleUploadChange}>
{fileList.length === 0 ? <Button loading={uploading} icon={<UploadOutlined/>}>点击上传</Button> : null}
</Upload>
</Form.Item>
<Form.Item required label="目标主机" help="可以通过创建多个发布申请单,选择主机分批发布。">
{host_ids.length > 0 && `已选择 ${host_ids.length}`}
<Form.Item required label="目标主机" tooltip="可以通过创建多个发布申请单,选择主机分批发布。">
{host_ids.length > 0 && `已选择 ${host_ids.length}(可选${app_host_ids.length}`}
<Button type="link" onClick={() => setVisible(true)}>选择主机</Button>
</Form.Item>
<Form.Item name="desc" label="备注信息">
<Input placeholder="请输入备注信息"/>
</Form.Item>
</Form>
{visible && <HostSelector
host_ids={host_ids}
app_host_ids={store.record.app_host_ids}
app_host_ids={app_host_ids}
onCancel={() => setVisible(false)}
onOk={ids => setHostIds(ids)}/>}
</Modal>

View File

@ -40,7 +40,7 @@ function ComTable() {
} else {
return (
<React.Fragment>
<BuildOutlined/> {info.extra[0]}
<BuildOutlined/> {info.version}
</React.Fragment>
)
}
@ -96,9 +96,7 @@ function ComTable() {
</Action>;
case '3':
return <Action>
<Action.Link
auth="deploy.request.do"
to={`/deploy/do/ext${info['app_extend']}/${info.id}/1`}>查看</Action.Link>
<Action.Button auth="deploy.request.do" onClick={() => store.readConsole(info)}>查看</Action.Button>
<Action.Button
auth="deploy.request.do"
disabled={info.type === '2'}
@ -130,23 +128,6 @@ function ComTable() {
}
}];
function handleRollback(info) {
http.put('/api/deploy/request/', {id: info.id, action: 'check'})
.then(res => {
Modal.confirm({
title: '回滚确认',
content: `确定要回滚至 ${res['date']} 创建的名称为【${res['name']}】的发布申请版本?`,
onOk: () => {
return http.put('/api/deploy/request/', {id: info.id, action: 'do'})
.then(() => {
message.success('回滚申请创建成功');
store.fetchRecords()
})
}
})
})
}
function handleDelete(info) {
Modal.confirm({
title: '删除确认',

View File

@ -13,6 +13,7 @@ import Ext2Form from './Ext2Form';
import Approve from './Approve';
import ComTable from './Table';
import Ext1Console from './Ext1Console';
import Ext2Console from './Ext2Console';
import { http, includes } from 'libs';
import envStore from 'pages/config/environment/store';
import appStore from 'pages/config/app/store';
@ -115,7 +116,11 @@ function Index() {
<Row gutter={12} className={styles.miniConsole}>
{store.tabs.map(item => (
<Col key={item.id}>
<Ext1Console request={item}/>
{item.app_extend === '1' ? (
<Ext1Console request={item}/>
) : (
<Ext2Console request={item}/>
)}
</Col>
))}
</Row>

View File

@ -19,6 +19,10 @@
box-shadow: 0 0 4px rgba(0, 0, 0, .3);
border-radius: 5px;
:global(.ant-progress-text) {
text-align: center;
}
.header {
display: flex;
justify-content: space-between;
@ -100,6 +104,10 @@
padding: 0;
}
.upload :global(.ant-upload-list-item) {
margin: 0;
}
.floatBox {
display: none;
position: fixed;