mirror of https://github.com/openspug/spug
A 批量执行新增执行记录
parent
9a08f25161
commit
5332d0ee36
|
@ -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)
|
||||
|
|
|
@ -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',)
|
||||
|
|
|
@ -7,5 +7,6 @@ from .views import *
|
|||
|
||||
urlpatterns = [
|
||||
url(r'template/$', TemplateView.as_view()),
|
||||
url(r'history/$', get_histories),
|
||||
url(r'do/$', do_task),
|
||||
]
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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()
|
||||
};
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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="请输入模板备注信息"/>
|
||||
|
|
|
@ -26,7 +26,7 @@ class Store {
|
|||
.finally(() => this.isFetching = false)
|
||||
};
|
||||
|
||||
showForm = (info = {}) => {
|
||||
showForm = (info = {interpreter: 'sh'}) => {
|
||||
this.formVisible = true;
|
||||
this.record = info
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue