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:
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.key = key
self.command = self._handle_command(command, interpreter)
@ -28,6 +28,8 @@ class Job:
SPUG_SSH_USERNAME=username,
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):
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')
host_ids = models.TextField(default='[]')
desc = models.CharField(max_length=255, null=True)
parameters = models.TextField(default='[]')
created_at = models.CharField(max_length=20, default=human_datetime)
created_by = models.ForeignKey(User, models.PROTECT, related_name='+')
updated_at = models.CharField(max_length=20, null=True)
@ -26,6 +26,7 @@ class ExecTemplate(models.Model, ModelMixin):
def to_view(self):
tmp = self.to_dict()
tmp['host_ids'] = json.loads(self.host_ids)
tmp['parameters'] = json.loads(self.parameters)
return tmp
class Meta:
@ -45,8 +46,11 @@ class ExecHistory(models.Model, ModelMixin):
def to_view(self):
tmp = self.to_dict()
tmp['host_ids'] = json.loads(self.host_ids)
if hasattr(self, 'template_name'):
tmp['template_name'] = self.template_name
if self.template:
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
class Meta:

View File

@ -31,6 +31,7 @@ class TemplateView(View):
Argument('body', help='请输入模版内容'),
Argument('interpreter', default='sh'),
Argument('host_ids', type=list, handler=json.dumps, default=[]),
Argument('parameters', type=list, handler=json.dumps, default=[]),
Argument('desc', required=False)
).parse(request.body)
if error is None:
@ -59,7 +60,8 @@ def do_task(request):
Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择执行主机'),
Argument('command', help='请输入执行命令内容'),
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)
if error is None:
if not has_host_perm(request.user, form.host_ids):
@ -76,6 +78,7 @@ def do_task(request):
username=host.username,
command=form.command,
pkey=host.private_key,
params=form.params
)
rds.rpush(settings.EXEC_WORKER_KEY, json.dumps(data))
form.host_ids.sort()
@ -106,5 +109,5 @@ def do_task(request):
@auth('exec.task.do')
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])

View File

@ -23,13 +23,13 @@ let gCurrent;
function OutView(props) {
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])
useEffect(() => {
store.tag = ''
gCurrent = current
const fitPlugin = new FitAddon()
term.setOption('disableStdin', false)
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'})
@ -39,7 +39,6 @@ function OutView(props) {
term.write('\x1b[36m### WebSocket connecting ...\x1b[0m')
const resize = () => fitPlugin.fit();
window.addEventListener('resize', resize)
setTerm(term)
return () => window.removeEventListener('resize', resize);
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -55,6 +54,7 @@ function OutView(props) {
}
term.write(message)
socket.send('ok');
fitPlugin.fit()
}
socket.onmessage = e => {
if (e.data === 'pong') {
@ -145,8 +145,8 @@ function OutView(props) {
<div className={style.title}>{store.outputs[current].title}</div>
<CodeOutlined className={style.icon} onClick={() => openTerminal(current)}/>
</div>
<div className={style.term}>
<div ref={el} style={{width: '100%'}}/>
<div className={style.termContainer}>
<div ref={el} className={style.term}/>
</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 Selector from 'pages/host/Selector';
import TemplateSelector from './TemplateSelector';
import Parameter from './Parameter';
import Output from './Output';
import { http, cleanCommand } from 'libs';
import moment from 'moment';
@ -22,6 +23,8 @@ function TaskIndex() {
const [command, setCommand] = useState('')
const [template_id, setTemplateId] = useState()
const [histories, setHistories] = useState([])
const [parameters, setParameters] = useState([])
const [visible, setVisible] = useState(false)
useEffect(() => {
if (!loading) {
@ -30,6 +33,12 @@ function TaskIndex() {
}
}, [loading])
useEffect(() => {
if (!command) {
setParameters([])
}
}, [command])
useEffect(() => {
return () => {
store.host_ids = []
@ -39,9 +48,12 @@ function TaskIndex() {
}
}, [])
function handleSubmit() {
function handleSubmit(params) {
if (!params && parameters.length > 0) {
return setVisible(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)
.then(store.switchConsole)
.finally(() => setLoading(false))
@ -52,12 +64,14 @@ function TaskIndex() {
setTemplateId(tpl.id)
setInterpreter(tpl.interpreter)
setCommand(tpl.body)
setParameters(tpl.parameters)
}
function handleClick(item) {
setTemplateId(item.template_id)
setInterpreter(item.interpreter)
setCommand(item.command)
setParameters(item.parameters || [])
store.host_ids = item.host_ids
}
@ -98,7 +112,8 @@ function TaskIndex() {
<Button style={{float: 'right'}} icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button>
<ACEditor className={style.editor} mode={interpreter} value={command} width="100%" onChange={setCommand}/>
</Form.Item>
<Button loading={loading} icon={<ThunderboltOutlined/>} type="primary" onClick={handleSubmit}>开始执行</Button>
<Button loading={loading} icon={<ThunderboltOutlined/>} type="primary"
onClick={() => handleSubmit()}>开始执行</Button>
</Form>
<div className={style.right}>
@ -124,14 +139,15 @@ function TaskIndex() {
</div>
</div>
</div>
{store.showTemplate &&
<TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
{store.showTemplate && <TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
{store.showConsole && <Output onBack={store.switchConsole}/>}
{visible && <Parameter parameters={parameters} onCancel={() => setVisible(false)} onOk={v => handleSubmit(v)}/>}
<Selector
visible={store.showHost}
selectedRowKeys={[...store.host_ids]}
onCancel={() => store.showHost = false}
onOk={(_, ids) => store.host_ids = ids}/>
</AuthDiv>
)
}

View File

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

View File

@ -3,32 +3,41 @@
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Modal, Form, Input, Select, Button, Radio, message } from 'antd';
import { ExclamationCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Modal, Form, Input, Select, Button, Radio, Table, Tooltip, message } from 'antd';
import { ACEditor } from 'components';
import Selector from 'pages/host/Selector';
import Parameter from './Parameter';
import { http, cleanCommand } from 'libs';
import store from './store';
import lds from 'lodash';
import S from './store';
export default observer(function () {
const [form] = Form.useForm();
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);
useEffect(() => {
setParameters(S.record.parameters)
}, [])
function handleSubmit() {
setLoading(true);
const formData = form.getFieldsValue();
formData['id'] = store.record.id;
formData['id'] = S.record.id;
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)
.then(res => {
message.success('操作成功');
store.formVisible = false;
store.fetchRecords()
S.formVisible = false;
S.fetchRecords()
}, () => setLoading(false))
}
@ -46,28 +55,45 @@ export default observer(function () {
),
onOk: () => {
if (type) {
store.types.push(type);
S.types.push(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 (
<Modal
visible
width={800}
maskClosable={false}
title={store.record.id ? '编辑模板' : '新建模板'}
onCancel={() => store.formVisible = false}
title={S.record.id ? '编辑模板' : '新建模板'}
onCancel={() => S.formVisible = false}
confirmLoading={loading}
onOk={handleSubmit}>
<Form form={form} initialValues={info} labelCol={{span: 6}} wrapperCol={{span: 14}}>
<Form.Item required label="模板类型" style={{marginBottom: 0}}>
<Form.Item name="type" style={{display: 'inline-block', width: 'calc(75%)', marginRight: 8}}>
<Select placeholder="请选择模板类型">
{store.types.map(item => (
{S.types.map(item => (
<Select.Option value={item} key={item}>{item}</Select.Option>
))}
</Select>
@ -91,9 +117,24 @@ export default observer(function () {
mode={getFieldValue('interpreter')}
value={body}
onChange={val => setBody(val)}
height="300px"/>
height="250px"/>
)}
</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="目标主机">
{info.host_ids.length > 0 && <span style={{marginRight: 16}}>已选择 {info.host_ids.length} </span>}
<Button type="link" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button>
@ -107,6 +148,13 @@ export default observer(function () {
selectedRowKeys={[...info.host_ids]}
onCancel={() => setVisible(false)}
onOk={(_, ids) => info.host_ids = ids}/>
{parameter ? (
<Parameter
parameter={parameter}
parameters={parameters}
onCancel={() => setParameter(null)}
onOk={updateParameter}/>
) : null}
</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';
class Store {
ParameterTypes = {
'string': '文本框',
'password': '密码框',
'select': '下拉选择'
}
@observable records = [];
@observable types = [];
@observable record = {};
@observable record = {parameters: []};
@observable isFetching = false;
@observable formVisible = false;
@ -33,7 +38,7 @@ class Store {
.finally(() => this.isFetching = false)
};
showForm = (info = {interpreter: 'sh', host_ids: []}) => {
showForm = (info = {interpreter: 'sh', host_ids: [], parameters: []}) => {
this.formVisible = true;
this.record = info
}