A 批量执行支持参数化

pull/467/head
vapao 2022-03-30 22:15:05 +08:00
parent 8dcf187e1e
commit 9d6b46fcb2
10 changed files with 255 additions and 36 deletions

View File

@ -14,7 +14,7 @@ def exec_worker_handler(job):
class Job: class Job:
def __init__(self, key, name, hostname, port, username, pkey, command, interpreter, token=None): def __init__(self, key, name, hostname, port, username, pkey, command, interpreter, params=None, token=None):
self.ssh = SSH(hostname, port, username, pkey) self.ssh = SSH(hostname, port, username, pkey)
self.key = key self.key = key
self.command = self._handle_command(command, interpreter) self.command = self._handle_command(command, interpreter)
@ -28,6 +28,8 @@ class Job:
SPUG_SSH_USERNAME=username, SPUG_SSH_USERNAME=username,
SPUG_INTERPRETER=interpreter SPUG_INTERPRETER=interpreter
) )
if isinstance(params, dict):
self.env.update({f'_SPUG_{k}': str(v) for k, v in params.items()})
def _send(self, message, with_expire=False): def _send(self, message, with_expire=False):
if self.rds_cli is None: if self.rds_cli is None:

View File

@ -14,7 +14,7 @@ class ExecTemplate(models.Model, ModelMixin):
interpreter = models.CharField(max_length=20, default='sh') interpreter = models.CharField(max_length=20, default='sh')
host_ids = models.TextField(default='[]') host_ids = models.TextField(default='[]')
desc = models.CharField(max_length=255, null=True) desc = models.CharField(max_length=255, null=True)
parameters = models.TextField(default='[]')
created_at = models.CharField(max_length=20, default=human_datetime) created_at = models.CharField(max_length=20, default=human_datetime)
created_by = models.ForeignKey(User, models.PROTECT, related_name='+') created_by = models.ForeignKey(User, models.PROTECT, related_name='+')
updated_at = models.CharField(max_length=20, null=True) updated_at = models.CharField(max_length=20, null=True)
@ -26,6 +26,7 @@ class ExecTemplate(models.Model, ModelMixin):
def to_view(self): def to_view(self):
tmp = self.to_dict() tmp = self.to_dict()
tmp['host_ids'] = json.loads(self.host_ids) tmp['host_ids'] = json.loads(self.host_ids)
tmp['parameters'] = json.loads(self.parameters)
return tmp return tmp
class Meta: class Meta:
@ -45,8 +46,11 @@ class ExecHistory(models.Model, ModelMixin):
def to_view(self): def to_view(self):
tmp = self.to_dict() tmp = self.to_dict()
tmp['host_ids'] = json.loads(self.host_ids) tmp['host_ids'] = json.loads(self.host_ids)
if hasattr(self, 'template_name'): if self.template:
tmp['template_name'] = self.template_name tmp['template_name'] = self.template.name
tmp['interpreter'] = self.template.interpreter
tmp['parameters'] = json.loads(self.template.parameters)
tmp['command'] = self.template.body
return tmp return tmp
class Meta: class Meta:

View File

@ -31,6 +31,7 @@ class TemplateView(View):
Argument('body', help='请输入模版内容'), Argument('body', help='请输入模版内容'),
Argument('interpreter', default='sh'), Argument('interpreter', default='sh'),
Argument('host_ids', type=list, handler=json.dumps, default=[]), Argument('host_ids', type=list, handler=json.dumps, default=[]),
Argument('parameters', type=list, handler=json.dumps, default=[]),
Argument('desc', required=False) Argument('desc', required=False)
).parse(request.body) ).parse(request.body)
if error is None: if error is None:
@ -59,7 +60,8 @@ def do_task(request):
Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择执行主机'), Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择执行主机'),
Argument('command', help='请输入执行命令内容'), Argument('command', help='请输入执行命令内容'),
Argument('interpreter', default='sh'), Argument('interpreter', default='sh'),
Argument('template_id', type=int, required=False) Argument('template_id', type=int, required=False),
Argument('params', type=dict, required=False)
).parse(request.body) ).parse(request.body)
if error is None: if error is None:
if not has_host_perm(request.user, form.host_ids): if not has_host_perm(request.user, form.host_ids):
@ -76,6 +78,7 @@ def do_task(request):
username=host.username, username=host.username,
command=form.command, command=form.command,
pkey=host.private_key, pkey=host.private_key,
params=form.params
) )
rds.rpush(settings.EXEC_WORKER_KEY, json.dumps(data)) rds.rpush(settings.EXEC_WORKER_KEY, json.dumps(data))
form.host_ids.sort() form.host_ids.sort()
@ -106,5 +109,5 @@ def do_task(request):
@auth('exec.task.do') @auth('exec.task.do')
def get_histories(request): def get_histories(request):
records = ExecHistory.objects.filter(user=request.user).annotate(template_name=F('template__name')) records = ExecHistory.objects.filter(user=request.user).select_related('template')
return json_response([x.to_view() for x in records]) return json_response([x.to_view() for x in records])

View File

@ -23,13 +23,13 @@ let gCurrent;
function OutView(props) { function OutView(props) {
const el = useRef() const el = useRef()
const [term, setTerm] = useState(new Terminal()) const [term] = useState(new Terminal());
const [fitPlugin] = useState(new FitAddon());
const [current, setCurrent] = useState(Object.keys(store.outputs)[0]) const [current, setCurrent] = useState(Object.keys(store.outputs)[0])
useEffect(() => { useEffect(() => {
store.tag = '' store.tag = ''
gCurrent = current gCurrent = current
const fitPlugin = new FitAddon()
term.setOption('disableStdin', false) term.setOption('disableStdin', false)
term.setOption('fontFamily', 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei') term.setOption('fontFamily', 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei')
term.setOption('theme', {background: '#f0f0f0', foreground: '#000', selection: '#999', cursor: '#f0f0f0'}) term.setOption('theme', {background: '#f0f0f0', foreground: '#000', selection: '#999', cursor: '#f0f0f0'})
@ -39,7 +39,6 @@ function OutView(props) {
term.write('\x1b[36m### WebSocket connecting ...\x1b[0m') term.write('\x1b[36m### WebSocket connecting ...\x1b[0m')
const resize = () => fitPlugin.fit(); const resize = () => fitPlugin.fit();
window.addEventListener('resize', resize) window.addEventListener('resize', resize)
setTerm(term)
return () => window.removeEventListener('resize', resize); return () => window.removeEventListener('resize', resize);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -55,6 +54,7 @@ function OutView(props) {
} }
term.write(message) term.write(message)
socket.send('ok'); socket.send('ok');
fitPlugin.fit()
} }
socket.onmessage = e => { socket.onmessage = e => {
if (e.data === 'pong') { if (e.data === 'pong') {
@ -145,8 +145,8 @@ function OutView(props) {
<div className={style.title}>{store.outputs[current].title}</div> <div className={style.title}>{store.outputs[current].title}</div>
<CodeOutlined className={style.icon} onClick={() => openTerminal(current)}/> <CodeOutlined className={style.icon} onClick={() => openTerminal(current)}/>
</div> </div>
<div className={style.term}> <div className={style.termContainer}>
<div ref={el} style={{width: '100%'}}/> <div ref={el} className={style.term}/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,65 @@
/**
* 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 { Modal, Form, Input, Select, message } from 'antd';
function Render(props) {
switch (props.type) {
case 'string':
return <Input value={props.value} onChange={props.onChange} placeholder="请输入"/>
case 'password':
return <Input.Password value={props.value} onChange={props.onChange} placeholder="请输入"/>
case 'select':
const options = props.options.split('\n').map(x => x.split(':'))
return (
<Select value={props.value} onChange={props.onChange} placeholder="请选择">
{options.map((item, index) => item.length > 1 ? (
<Select.Option key={index} value={item[0]}>{item[1]}</Select.Option>
) : (
<Select.Option key={index} value={item[0]}>{item[0]}</Select.Option>
))}
</Select>
)
default:
return null
}
}
export default function Parameter(props) {
const [form] = Form.useForm();
function handleSubmit() {
const formData = form.getFieldsValue();
for (let item of props.parameters.filter(x => x.required)) {
if (!formData[item.variable]) {
return message.error(`${item.name} 是必填项。`)
}
}
props.onOk(formData);
props.onCancel()
}
return (
<Modal
visible
width={600}
maskClosable={false}
title="执行任务"
onCancel={props.onCancel}
okText="立即执行"
onOk={handleSubmit}>
<Form form={form} initialValues={props.parameter} labelCol={{span: 6}} wrapperCol={{span: 14}}>
{props.parameters.map(item => (
<Form.Item required={item.required} key={item.variable} name={item.variable} label={item.name}
tooltip={item.desc} initialValue={item.default}>
<Render type={item.type} options={item.options}/>
</Form.Item>
))}
</Form>
</Modal>
)
}

View File

@ -10,6 +10,7 @@ import { Form, Button, Alert, Radio, Tooltip } from 'antd';
import { ACEditor, AuthDiv, Breadcrumb } from 'components'; import { ACEditor, AuthDiv, Breadcrumb } from 'components';
import Selector from 'pages/host/Selector'; import Selector from 'pages/host/Selector';
import TemplateSelector from './TemplateSelector'; import TemplateSelector from './TemplateSelector';
import Parameter from './Parameter';
import Output from './Output'; import Output from './Output';
import { http, cleanCommand } from 'libs'; import { http, cleanCommand } from 'libs';
import moment from 'moment'; import moment from 'moment';
@ -22,6 +23,8 @@ function TaskIndex() {
const [command, setCommand] = useState('') const [command, setCommand] = useState('')
const [template_id, setTemplateId] = useState() const [template_id, setTemplateId] = useState()
const [histories, setHistories] = useState([]) const [histories, setHistories] = useState([])
const [parameters, setParameters] = useState([])
const [visible, setVisible] = useState(false)
useEffect(() => { useEffect(() => {
if (!loading) { if (!loading) {
@ -30,6 +33,12 @@ function TaskIndex() {
} }
}, [loading]) }, [loading])
useEffect(() => {
if (!command) {
setParameters([])
}
}, [command])
useEffect(() => { useEffect(() => {
return () => { return () => {
store.host_ids = [] store.host_ids = []
@ -39,9 +48,12 @@ function TaskIndex() {
} }
}, []) }, [])
function handleSubmit() { function handleSubmit(params) {
if (!params && parameters.length > 0) {
return setVisible(true)
}
setLoading(true) setLoading(true)
const formData = {interpreter, template_id, host_ids: store.host_ids, command: cleanCommand(command)} const formData = {interpreter, template_id, params, host_ids: store.host_ids, command: cleanCommand(command)}
http.post('/api/exec/do/', formData) http.post('/api/exec/do/', formData)
.then(store.switchConsole) .then(store.switchConsole)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@ -52,12 +64,14 @@ function TaskIndex() {
setTemplateId(tpl.id) setTemplateId(tpl.id)
setInterpreter(tpl.interpreter) setInterpreter(tpl.interpreter)
setCommand(tpl.body) setCommand(tpl.body)
setParameters(tpl.parameters)
} }
function handleClick(item) { function handleClick(item) {
setTemplateId(item.template_id) setTemplateId(item.template_id)
setInterpreter(item.interpreter) setInterpreter(item.interpreter)
setCommand(item.command) setCommand(item.command)
setParameters(item.parameters || [])
store.host_ids = item.host_ids store.host_ids = item.host_ids
} }
@ -98,7 +112,8 @@ function TaskIndex() {
<Button style={{float: 'right'}} icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button> <Button style={{float: 'right'}} icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button>
<ACEditor className={style.editor} mode={interpreter} value={command} width="100%" onChange={setCommand}/> <ACEditor className={style.editor} mode={interpreter} value={command} width="100%" onChange={setCommand}/>
</Form.Item> </Form.Item>
<Button loading={loading} icon={<ThunderboltOutlined/>} type="primary" onClick={handleSubmit}>开始执行</Button> <Button loading={loading} icon={<ThunderboltOutlined/>} type="primary"
onClick={() => handleSubmit()}>开始执行</Button>
</Form> </Form>
<div className={style.right}> <div className={style.right}>
@ -124,14 +139,15 @@ function TaskIndex() {
</div> </div>
</div> </div>
</div> </div>
{store.showTemplate && {store.showTemplate && <TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
<TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
{store.showConsole && <Output onBack={store.switchConsole}/>} {store.showConsole && <Output onBack={store.switchConsole}/>}
{visible && <Parameter parameters={parameters} onCancel={() => setVisible(false)} onOk={v => handleSubmit(v)}/>}
<Selector <Selector
visible={store.showHost} visible={store.showHost}
selectedRowKeys={[...store.host_ids]} selectedRowKeys={[...store.host_ids]}
onCancel={() => store.showHost = false} onCancel={() => store.showHost = false}
onOk={(_, ids) => store.host_ids = ids}/> onOk={(_, ids) => store.host_ids = ids}/>
</AuthDiv> </AuthDiv>
) )
} }

View File

@ -34,6 +34,7 @@
.right { .right {
width: 40%; width: 40%;
max-width: 600px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #fafafa; background-color: #fafafa;
@ -253,11 +254,15 @@
} }
.term { .termContainer {
flex: 1;
background-color: #f0f0f0; background-color: #f0f0f0;
padding: 8px 0 8px 12px; padding: 8px 0 4px 12px;
border-radius: 2px; border-radius: 2px;
.term {
width: 100%;
height: calc(100vh - 300px);
}
} }
} }
} }

View File

@ -3,32 +3,41 @@
* Copyright (c) <spug.dev@gmail.com> * Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License. * Released under the AGPL-3.0 License.
*/ */
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { ExclamationCircleOutlined } from '@ant-design/icons'; import { ExclamationCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Modal, Form, Input, Select, Button, Radio, message } from 'antd'; import { Modal, Form, Input, Select, Button, Radio, Table, Tooltip, message } from 'antd';
import { ACEditor } from 'components'; import { ACEditor } from 'components';
import Selector from 'pages/host/Selector'; import Selector from 'pages/host/Selector';
import Parameter from './Parameter';
import { http, cleanCommand } from 'libs'; import { http, cleanCommand } from 'libs';
import store from './store'; import lds from 'lodash';
import S from './store';
export default observer(function () { export default observer(function () {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [body, setBody] = useState(store.record.body); const [body, setBody] = useState(S.record.body);
const [parameter, setParameter] = useState();
const [parameters, setParameters] = useState([]);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
useEffect(() => {
setParameters(S.record.parameters)
}, [])
function handleSubmit() { function handleSubmit() {
setLoading(true); setLoading(true);
const formData = form.getFieldsValue(); const formData = form.getFieldsValue();
formData['id'] = store.record.id; formData['id'] = S.record.id;
formData['body'] = cleanCommand(body); formData['body'] = cleanCommand(body);
formData['host_ids'] = store.record.host_ids; formData['host_ids'] = S.record.host_ids;
formData['parameters'] = parameters;
http.post('/api/exec/template/', formData) http.post('/api/exec/template/', formData)
.then(res => { .then(res => {
message.success('操作成功'); message.success('操作成功');
store.formVisible = false; S.formVisible = false;
store.fetchRecords() S.fetchRecords()
}, () => setLoading(false)) }, () => setLoading(false))
} }
@ -46,28 +55,45 @@ export default observer(function () {
), ),
onOk: () => { onOk: () => {
if (type) { if (type) {
store.types.push(type); S.types.push(type);
form.setFieldsValue({type}) form.setFieldsValue({type})
} }
}, },
}) })
} }
const info = store.record; function updateParameter(data) {
if (data.id) {
const index = lds.findIndex(parameters, {id: data.id})
parameters[index] = data
} else {
data.id = parameters.length + 1
parameters.push(data)
}
setParameters([...parameters])
setParameter(null)
}
function delParameter(index) {
parameters.splice(index, 1)
setParameters([...parameters])
}
const info = S.record;
return ( return (
<Modal <Modal
visible visible
width={800} width={800}
maskClosable={false} maskClosable={false}
title={store.record.id ? '编辑模板' : '新建模板'} title={S.record.id ? '编辑模板' : '新建模板'}
onCancel={() => store.formVisible = false} onCancel={() => S.formVisible = false}
confirmLoading={loading} confirmLoading={loading}
onOk={handleSubmit}> onOk={handleSubmit}>
<Form form={form} initialValues={info} labelCol={{span: 6}} wrapperCol={{span: 14}}> <Form form={form} initialValues={info} labelCol={{span: 6}} wrapperCol={{span: 14}}>
<Form.Item required label="模板类型" style={{marginBottom: 0}}> <Form.Item required label="模板类型" style={{marginBottom: 0}}>
<Form.Item name="type" style={{display: 'inline-block', width: 'calc(75%)', marginRight: 8}}> <Form.Item name="type" style={{display: 'inline-block', width: 'calc(75%)', marginRight: 8}}>
<Select placeholder="请选择模板类型"> <Select placeholder="请选择模板类型">
{store.types.map(item => ( {S.types.map(item => (
<Select.Option value={item} key={item}>{item}</Select.Option> <Select.Option value={item} key={item}>{item}</Select.Option>
))} ))}
</Select> </Select>
@ -91,9 +117,24 @@ export default observer(function () {
mode={getFieldValue('interpreter')} mode={getFieldValue('interpreter')}
value={body} value={body}
onChange={val => setBody(val)} onChange={val => setBody(val)}
height="300px"/> height="250px"/>
)} )}
</Form.Item> </Form.Item>
<Form.Item label="参数化">
{parameters.length > 0 && (
<Table pagination={false} bordered rowKey="id" size="small" dataSource={parameters}>
<Table.Column title="参数名" dataIndex="name"
render={(_, row) => <Tooltip title={row.desc}>{row.name}</Tooltip>}/>
<Table.Column title="变量名" dataIndex="variable"/>
<Table.Column title="操作" width={90} render={(item, _, index) => [
<Button key="1" type="link" icon={<EditOutlined/>} onClick={() => setParameter(item)}/>,
<Button danger key="2" type="link" icon={<DeleteOutlined/>} onClick={() => delParameter(index)}/>
]}>
</Table.Column>
</Table>
)}
<Button type="link" style={{padding: 0}} onClick={() => setParameter({})}>添加参数</Button>
</Form.Item>
<Form.Item label="目标主机"> <Form.Item label="目标主机">
{info.host_ids.length > 0 && <span style={{marginRight: 16}}>已选择 {info.host_ids.length} </span>} {info.host_ids.length > 0 && <span style={{marginRight: 16}}>已选择 {info.host_ids.length} </span>}
<Button type="link" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button> <Button type="link" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button>
@ -107,6 +148,13 @@ export default observer(function () {
selectedRowKeys={[...info.host_ids]} selectedRowKeys={[...info.host_ids]}
onCancel={() => setVisible(false)} onCancel={() => setVisible(false)}
onOk={(_, ids) => info.host_ids = ids}/> onOk={(_, ids) => info.host_ids = ids}/>
{parameter ? (
<Parameter
parameter={parameter}
parameters={parameters}
onCancel={() => setParameter(null)}
onOk={updateParameter}/>
) : null}
</Modal> </Modal>
) )
}) })

View File

@ -0,0 +1,71 @@
/**
* 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 { Modal, Form, Input, Radio, Switch, message } from 'antd';
import S from './store';
import lds from 'lodash';
export default function Parameter(props) {
const [form] = Form.useForm();
function handleSubmit() {
const formData = form.getFieldsValue();
console.log(formData)
formData.id = props.parameter.id
if (!formData.name) return message.error('请输入参数名')
if (!formData.variable) return message.error('请输入变量名')
if (!formData.type) return message.error('请选择参数类型')
if (formData.type === 'select' && !formData.options) return message.error('请输入可选项')
const tmp = lds.find(props.parameters, {variable: formData.variable})
if (tmp && tmp.id !== formData.id) return message.error('变量名重复')
props.onOk(formData)
}
return (
<Modal
visible
width={600}
maskClosable={false}
title="编辑参数"
onCancel={props.onCancel}
onOk={handleSubmit}>
<Form form={form} initialValues={props.parameter} labelCol={{span: 6}} wrapperCol={{span: 14}}>
<Form.Item required name="name" label="参数名" tooltip="参数的简短名称。">
<Input placeholder="请输入参数名称"/>
</Form.Item>
<Form.Item required name="variable" label="变量名"
tooltip="在脚本使用的变量名称固定前缀_SPUG_ + 输入的变量名例如变量名name则最终生成环境变量为 _SPUG_name">
<Input placeholder="请输入变量名"/>
</Form.Item>
<Form.Item required name="type" label="参数类型" tooltip="不同类型展示的形式不同。">
<Radio.Group style={{width: '100%'}}>
{Object.entries(S.ParameterTypes).map(([key, val]) => (
<Radio.Button key={key} value={key}>{val}</Radio.Button>
))}
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({getFieldValue}) =>
['select'].includes(getFieldValue('type')) ? (
<Form.Item required name="options" label="可选项" tooltip="每项单独一行,每行可以用英文冒号分割前边是值后边是显示的内容。">
<Input.TextArea autoSize={{minRows: 3, maxRows: 5}} placeholder="每行一个选项,例如: test:测试环境"/>
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="required" valuePropName="checked" label="必填" tooltip="该参数是否为必填项">
<Switch checkedChildren="是" unCheckedChildren="否"/>
</Form.Item>
<Form.Item name="default" label="默认值">
<Input placeholder="请输入"/>
</Form.Item>
<Form.Item name="desc" label="提示信息" tooltip="会展示在参数的输入框下方。">
<Input placeholder="请输入该参数的帮助提示信息"/>
</Form.Item>
</Form>
</Modal>
)
}

View File

@ -7,9 +7,14 @@ import { observable } from "mobx";
import { http, includes } from 'libs'; import { http, includes } from 'libs';
class Store { class Store {
ParameterTypes = {
'string': '文本框',
'password': '密码框',
'select': '下拉选择'
}
@observable records = []; @observable records = [];
@observable types = []; @observable types = [];
@observable record = {}; @observable record = {parameters: []};
@observable isFetching = false; @observable isFetching = false;
@observable formVisible = false; @observable formVisible = false;
@ -33,7 +38,7 @@ class Store {
.finally(() => this.isFetching = false) .finally(() => this.isFetching = false)
}; };
showForm = (info = {interpreter: 'sh', host_ids: []}) => { showForm = (info = {interpreter: 'sh', host_ids: [], parameters: []}) => {
this.formVisible = true; this.formVisible = true;
this.record = info this.record = info
} }