A 批量执行新增执行记录

pull/410/head
vapao 2021-11-29 19:01:58 +08:00
parent 9a08f25161
commit 5332d0ee36
9 changed files with 243 additions and 74 deletions

View File

@ -14,10 +14,10 @@ def exec_worker_handler(job):
class Job:
def __init__(self, key, name, hostname, port, username, pkey, command, token=None):
def __init__(self, key, name, hostname, port, username, pkey, command, interpreter, token=None):
self.ssh = SSH(hostname, port, username, pkey)
self.key = key
self.command = command
self.command = self._handle_command(command, interpreter)
self.token = token
self.rds_cli = None
self.env = dict(
@ -35,6 +35,11 @@ class Job:
if with_expire:
self.rds_cli.expire(self.token, 300)
def _handle_command(self, command, interpreter):
if interpreter == 'python':
return f'python << EOF\n{command}\nEOF'
return command
def send(self, data):
message = {'key': self.key, 'data': data}
self._send(message)

View File

@ -4,12 +4,14 @@
from django.db import models
from libs import ModelMixin, human_datetime
from apps.account.models import User
import json
class ExecTemplate(models.Model, ModelMixin):
name = models.CharField(max_length=50)
type = models.CharField(max_length=50)
body = models.TextField()
interpreter = models.CharField(max_length=20)
desc = models.CharField(max_length=255, null=True)
created_at = models.CharField(max_length=20, default=human_datetime)
@ -23,3 +25,20 @@ class ExecTemplate(models.Model, ModelMixin):
class Meta:
db_table = 'exec_templates'
ordering = ('-id',)
class ExecHistory(models.Model, ModelMixin):
digest = models.CharField(max_length=32, unique=True)
interpreter = models.CharField(max_length=20)
command = models.TextField()
host_ids = models.TextField()
updated_at = models.CharField(max_length=20, default=human_datetime)
def to_view(self):
tmp = self.to_dict()
tmp['host_ids'] = json.loads(self.host_ids)
return tmp
class Meta:
db_table = 'exec_histories'
ordering = ('-id',)

View File

@ -7,5 +7,6 @@ from .views import *
urlpatterns = [
url(r'template/$', TemplateView.as_view()),
url(r'history/$', get_histories),
url(r'do/$', do_task),
]

View File

@ -5,9 +5,10 @@ from django.views.generic import View
from django_redis import get_redis_connection
from django.conf import settings
from libs import json_response, JsonParser, Argument, human_datetime, auth
from apps.exec.models import ExecTemplate
from apps.exec.models import ExecTemplate, ExecHistory
from apps.host.models import Host
from apps.account.utils import has_host_perm
import hashlib
import uuid
import json
@ -26,6 +27,7 @@ class TemplateView(View):
Argument('name', help='请输入模版名称'),
Argument('type', help='请选择模版类型'),
Argument('body', help='请输入模版内容'),
Argument('interpreter', default='sh'),
Argument('desc', required=False)
).parse(request.body)
if error is None:
@ -52,7 +54,8 @@ class TemplateView(View):
def do_task(request):
form, error = JsonParser(
Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择执行主机'),
Argument('command', help='请输入执行命令内容')
Argument('command', help='请输入执行命令内容'),
Argument('interpreter', default='sh')
).parse(request.body)
if error is None:
if not has_host_perm(request.user, form.host_ids):
@ -63,6 +66,7 @@ def do_task(request):
key=host.id,
name=host.name,
token=token,
interpreter=form.interpreter,
hostname=host.hostname,
port=host.port,
username=host.username,
@ -70,5 +74,26 @@ def do_task(request):
pkey=host.private_key,
)
rds.rpush(settings.EXEC_WORKER_KEY, json.dumps(data))
form.host_ids.sort()
host_ids = json.dumps(form.host_ids)
tmp_str = f'{form.interpreter},{host_ids},{form.command}'
digest = hashlib.md5(tmp_str.encode()).hexdigest()
record = ExecHistory.objects.filter(digest=digest).first()
if record:
record.updated_at = human_datetime()
record.save()
else:
ExecHistory.objects.create(
digest=digest,
interpreter=form.interpreter,
command=form.command,
host_ids=json.dumps(form.host_ids),
)
return json_response(token)
return json_response(error=error)
@auth('exec.task.do')
def get_histories(request):
records = ExecHistory.objects.all()
return json_response([x.to_view() for x in records])

View File

@ -32,7 +32,8 @@ class TemplateSelector extends React.Component {
handleSubmit = () => {
if (this.state.selectedRows.length > 0) {
this.props.onOk(this.state.selectedRows[0].body)
const {body, interpreter} = this.state.selectedRows[0]
this.props.onOk(body, interpreter)
}
this.props.onCancel()
};

View File

@ -3,41 +3,49 @@
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React from 'react';
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react';
import { PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { Form, Button, Card, Alert } from 'antd';
import { PlusOutlined, ThunderboltOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Form, Button, Card, Alert, Radio, Tooltip } from 'antd';
import { ACEditor, AuthDiv, Breadcrumb } from 'components';
import Selector from 'pages/host/Selector';
import TemplateSelector from './TemplateSelector';
import ExecConsole from './ExecConsole';
import { http, cleanCommand } from 'libs';
import moment from 'moment';
import store from './store';
import style from './index.module.less';
@observer
class TaskIndex extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: false,
body: '',
}
}
function TaskIndex() {
const [loading, setLoading] = useState(false)
const [interpreter, setInterpreter] = useState('sh')
const [command, setCommand] = useState('')
const [histories, setHistories] = useState([])
componentWillUnmount() {
store.host_ids = []
}
useEffect(() => {
http.get('/api/exec/history/')
.then(res => setHistories(res))
}, [])
handleSubmit = () => {
this.setState({loading: true});
function handleSubmit() {
setLoading(true)
const host_ids = store.host_ids;
http.post('/api/exec/do/', {host_ids, command: cleanCommand(this.state.body)})
http.post('/api/exec/do/', {host_ids, interpreter, command: cleanCommand(command)})
.then(store.switchConsole)
.finally(() => this.setState({loading: false}))
};
.finally(() => setLoading(false))
}
function handleTemplate(command, interpreter) {
setInterpreter(interpreter)
setCommand(command)
}
function handleClick(item) {
setInterpreter(item.interpreter)
setCommand(item.command)
store.host_ids = item.host_ids
}
render() {
const {body, token} = this.state;
return (
<AuthDiv auth="exec.task.do">
<Breadcrumb>
@ -45,8 +53,8 @@ class TaskIndex extends React.Component {
<Breadcrumb.Item>批量执行</Breadcrumb.Item>
<Breadcrumb.Item>执行任务</Breadcrumb.Item>
</Breadcrumb>
<Card>
<Form layout="vertical">
<Card bodyStyle={{display: 'flex', padding: 0}}>
<Form layout="vertical" style={{padding: 24, width: '60%'}}>
<Form.Item required label="目标主机">
{store.host_ids.length > 0 && (
<Alert style={{width: 200}} type="info" message={`已选择 ${store.host_ids.length} 台主机`}/>
@ -56,26 +64,51 @@ class TaskIndex extends React.Component {
style={{marginBottom: 24}}
icon={<PlusOutlined/>}
onClick={() => store.showHost = true}>从主机列表中选择</Button>
<Form.Item label="执行命令">
<ACEditor mode="sh" value={body} height="300px" width="700px" onChange={body => this.setState({body})}/>
<Form.Item required label="执行命令">
<Radio.Group
buttonStyle="solid"
style={{marginBottom: 12}}
value={interpreter}
onChange={e => setInterpreter(e.target.value)}>
<Radio.Button value="sh">Shell</Radio.Button>
<Radio.Button value="python">Python</Radio.Button>
</Radio.Group>
<ACEditor mode={interpreter} value={command} height="350px" width="100%" onChange={setCommand}/>
</Form.Item>
<Form.Item>
<Button icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button>
</Form.Item>
<Button icon={<ThunderboltOutlined/>} type="primary" onClick={this.handleSubmit}>开始执行</Button>
<Button loading={loading} icon={<ThunderboltOutlined/>} type="primary" onClick={handleSubmit}>开始执行</Button>
</Form>
<div className={style.hisBlock}>
<div className={style.title}>
执行记录
<Tooltip title="每天自动清理保留最近50条记录。">
<QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>
</Tooltip>
</div>
<div className={style.inner}>
{histories.map((item, index) => (
<div key={index} className={style.item} onClick={() => handleClick(item)}>
<div className={style[item.interpreter]}>{item.interpreter.substr(0, 2)}</div>
<div className={style.number}>{item.host_ids.length}</div>
<div className={style.command}>{item.command}</div>
<div className={style.desc}>{moment(item.updated_at).format('MM.DD HH:mm')}</div>
</div>
))}
</div>
</div>
</Card>
{store.showTemplate &&
<TemplateSelector onCancel={store.switchTemplate} onOk={v => this.setState({body: body + v})}/>}
{store.showConsole && <ExecConsole token={token} onCancel={store.switchConsole}/>}
<TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
{store.showConsole && <ExecConsole onCancel={store.switchConsole}/>}
<Selector
visible={store.showHost}
selectedRowKeys={[...store.host_ids]}
onCancel={() => store.showHost = false}
onOk={(_, ids) => store.host_ids = ids}/>
</AuthDiv>
);
}
)
}
export default TaskIndex
export default observer(TaskIndex)

View File

@ -42,3 +42,80 @@
pre {
margin: 0;
}
.hisBlock {
width: 40%;
display: flex;
flex-direction: column;
background-color: #fafafa;
padding: 24px;
overflow: auto;
.title {
font-weight: 500;
margin-bottom: 12px;
}
.inner {
max-height: 700px;
overflow: auto;
}
.item {
display: flex;
align-items: center;
border-radius: 2px;
padding: 8px 12px;
cursor: pointer;
margin-bottom: 12px;
.sh {
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
color: #fff;
font-weight: 500;
border-radius: 2px;
background-color: #1890ff;
}
.python {
display: flex;
justify-content: center;
align-items: center;
color: #fff;
width: 20px;
height: 20px;
font-weight: 500;
border-radius: 2px;
background-color: #dca900;
}
.number {
width: 24px;
text-align: center;
margin-left: 12px;
border-radius: 2px;
font-weight: 500;
background-color:#dfdfdf;
}
.command {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 12px;
}
.desc {
color: #999;
}
}
.item:hover {
border-color: #1890ff;
background-color: #e6f7ff;
}
}

View File

@ -6,7 +6,7 @@
import React, { useState } from 'react';
import { observer } from 'mobx-react';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Modal, Form, Input, Select, Button, message } from 'antd';
import { Modal, Form, Input, Select, Button, Radio, message } from 'antd';
import { ACEditor } from 'components';
import { http, cleanCommand } from 'libs';
import store from './store';
@ -76,12 +76,20 @@ export default observer(function () {
<Form.Item required name="name" label="模板名称">
<Input placeholder="请输入模板名称"/>
</Form.Item>
<Form.Item required label="模板内容">
<Form.Item required name="interpreter" label="脚本语言">
<Radio.Group>
<Radio.Button value="sh">Shell</Radio.Button>
<Radio.Button value="python">Python</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item required label="模板内容" shouldUpdate={(p, c) => p.interpreter !== c.interpreter}>
{({getFieldValue}) => (
<ACEditor
mode="sh"
mode={getFieldValue('interpreter')}
value={body}
onChange={val => setBody(val)}
height="300px"/>
)}
</Form.Item>
<Form.Item name="desc" label="备注信息">
<Input.TextArea placeholder="请输入模板备注信息"/>

View File

@ -26,7 +26,7 @@ class Store {
.finally(() => this.isFetching = false)
};
showForm = (info = {}) => {
showForm = (info = {interpreter: 'sh'}) => {
this.formVisible = true;
this.record = info
}