mirror of https://github.com/openspug/spug
A 批量执行新增执行记录
parent
9a08f25161
commit
5332d0ee36
|
@ -14,10 +14,10 @@ def exec_worker_handler(job):
|
||||||
|
|
||||||
|
|
||||||
class 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.ssh = SSH(hostname, port, username, pkey)
|
||||||
self.key = key
|
self.key = key
|
||||||
self.command = command
|
self.command = self._handle_command(command, interpreter)
|
||||||
self.token = token
|
self.token = token
|
||||||
self.rds_cli = None
|
self.rds_cli = None
|
||||||
self.env = dict(
|
self.env = dict(
|
||||||
|
@ -35,6 +35,11 @@ class Job:
|
||||||
if with_expire:
|
if with_expire:
|
||||||
self.rds_cli.expire(self.token, 300)
|
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):
|
def send(self, data):
|
||||||
message = {'key': self.key, 'data': data}
|
message = {'key': self.key, 'data': data}
|
||||||
self._send(message)
|
self._send(message)
|
||||||
|
|
|
@ -4,12 +4,14 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from libs import ModelMixin, human_datetime
|
from libs import ModelMixin, human_datetime
|
||||||
from apps.account.models import User
|
from apps.account.models import User
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
class ExecTemplate(models.Model, ModelMixin):
|
class ExecTemplate(models.Model, ModelMixin):
|
||||||
name = models.CharField(max_length=50)
|
name = models.CharField(max_length=50)
|
||||||
type = models.CharField(max_length=50)
|
type = models.CharField(max_length=50)
|
||||||
body = models.TextField()
|
body = models.TextField()
|
||||||
|
interpreter = models.CharField(max_length=20)
|
||||||
desc = models.CharField(max_length=255, null=True)
|
desc = models.CharField(max_length=255, null=True)
|
||||||
|
|
||||||
created_at = models.CharField(max_length=20, default=human_datetime)
|
created_at = models.CharField(max_length=20, default=human_datetime)
|
||||||
|
@ -23,3 +25,20 @@ class ExecTemplate(models.Model, ModelMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'exec_templates'
|
db_table = 'exec_templates'
|
||||||
ordering = ('-id',)
|
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 = [
|
urlpatterns = [
|
||||||
url(r'template/$', TemplateView.as_view()),
|
url(r'template/$', TemplateView.as_view()),
|
||||||
|
url(r'history/$', get_histories),
|
||||||
url(r'do/$', do_task),
|
url(r'do/$', do_task),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,9 +5,10 @@ from django.views.generic import View
|
||||||
from django_redis import get_redis_connection
|
from django_redis import get_redis_connection
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from libs import json_response, JsonParser, Argument, human_datetime, auth
|
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.host.models import Host
|
||||||
from apps.account.utils import has_host_perm
|
from apps.account.utils import has_host_perm
|
||||||
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
@ -26,6 +27,7 @@ class TemplateView(View):
|
||||||
Argument('name', help='请输入模版名称'),
|
Argument('name', help='请输入模版名称'),
|
||||||
Argument('type', help='请选择模版类型'),
|
Argument('type', help='请选择模版类型'),
|
||||||
Argument('body', help='请输入模版内容'),
|
Argument('body', help='请输入模版内容'),
|
||||||
|
Argument('interpreter', default='sh'),
|
||||||
Argument('desc', required=False)
|
Argument('desc', required=False)
|
||||||
).parse(request.body)
|
).parse(request.body)
|
||||||
if error is None:
|
if error is None:
|
||||||
|
@ -52,7 +54,8 @@ class TemplateView(View):
|
||||||
def do_task(request):
|
def do_task(request):
|
||||||
form, error = JsonParser(
|
form, error = JsonParser(
|
||||||
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')
|
||||||
).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):
|
||||||
|
@ -63,6 +66,7 @@ def do_task(request):
|
||||||
key=host.id,
|
key=host.id,
|
||||||
name=host.name,
|
name=host.name,
|
||||||
token=token,
|
token=token,
|
||||||
|
interpreter=form.interpreter,
|
||||||
hostname=host.hostname,
|
hostname=host.hostname,
|
||||||
port=host.port,
|
port=host.port,
|
||||||
username=host.username,
|
username=host.username,
|
||||||
|
@ -70,5 +74,26 @@ def do_task(request):
|
||||||
pkey=host.private_key,
|
pkey=host.private_key,
|
||||||
)
|
)
|
||||||
rds.rpush(settings.EXEC_WORKER_KEY, json.dumps(data))
|
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(token)
|
||||||
return json_response(error=error)
|
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 = () => {
|
handleSubmit = () => {
|
||||||
if (this.state.selectedRows.length > 0) {
|
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()
|
this.props.onCancel()
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,79 +3,112 @@
|
||||||
* 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 from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
import { PlusOutlined, ThunderboltOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import { Form, Button, Card, Alert } from 'antd';
|
import { Form, Button, Card, 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 ExecConsole from './ExecConsole';
|
import ExecConsole from './ExecConsole';
|
||||||
import { http, cleanCommand } from 'libs';
|
import { http, cleanCommand } from 'libs';
|
||||||
|
import moment from 'moment';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
import style from './index.module.less';
|
||||||
|
|
||||||
@observer
|
function TaskIndex() {
|
||||||
class TaskIndex extends React.Component {
|
const [loading, setLoading] = useState(false)
|
||||||
constructor(props) {
|
const [interpreter, setInterpreter] = useState('sh')
|
||||||
super(props);
|
const [command, setCommand] = useState('')
|
||||||
this.state = {
|
const [histories, setHistories] = useState([])
|
||||||
loading: false,
|
|
||||||
body: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
useEffect(() => {
|
||||||
store.host_ids = []
|
http.get('/api/exec/history/')
|
||||||
}
|
.then(res => setHistories(res))
|
||||||
|
}, [])
|
||||||
|
|
||||||
handleSubmit = () => {
|
function handleSubmit() {
|
||||||
this.setState({loading: true});
|
setLoading(true)
|
||||||
const host_ids = store.host_ids;
|
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)
|
.then(store.switchConsole)
|
||||||
.finally(() => this.setState({loading: false}))
|
.finally(() => setLoading(false))
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {body, token} = this.state;
|
|
||||||
return (
|
|
||||||
<AuthDiv auth="exec.task.do">
|
|
||||||
<Breadcrumb>
|
|
||||||
<Breadcrumb.Item>首页</Breadcrumb.Item>
|
|
||||||
<Breadcrumb.Item>批量执行</Breadcrumb.Item>
|
|
||||||
<Breadcrumb.Item>执行任务</Breadcrumb.Item>
|
|
||||||
</Breadcrumb>
|
|
||||||
<Card>
|
|
||||||
<Form layout="vertical">
|
|
||||||
<Form.Item required label="目标主机">
|
|
||||||
{store.host_ids.length > 0 && (
|
|
||||||
<Alert style={{width: 200}} type="info" message={`已选择 ${store.host_ids.length} 台主机`}/>
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
<Button
|
|
||||||
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>
|
|
||||||
<Form.Item>
|
|
||||||
<Button icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button>
|
|
||||||
</Form.Item>
|
|
||||||
<Button icon={<ThunderboltOutlined/>} type="primary" onClick={this.handleSubmit}>开始执行</Button>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
{store.showTemplate &&
|
|
||||||
<TemplateSelector onCancel={store.switchTemplate} onOk={v => this.setState({body: body + v})}/>}
|
|
||||||
{store.showConsole && <ExecConsole token={token} onCancel={store.switchConsole}/>}
|
|
||||||
<Selector
|
|
||||||
visible={store.showHost}
|
|
||||||
selectedRowKeys={[...store.host_ids]}
|
|
||||||
onCancel={() => store.showHost = false}
|
|
||||||
onOk={(_, ids) => store.host_ids = ids}/>
|
|
||||||
</AuthDiv>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTemplate(command, interpreter) {
|
||||||
|
setInterpreter(interpreter)
|
||||||
|
setCommand(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(item) {
|
||||||
|
setInterpreter(item.interpreter)
|
||||||
|
setCommand(item.command)
|
||||||
|
store.host_ids = item.host_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthDiv auth="exec.task.do">
|
||||||
|
<Breadcrumb>
|
||||||
|
<Breadcrumb.Item>首页</Breadcrumb.Item>
|
||||||
|
<Breadcrumb.Item>批量执行</Breadcrumb.Item>
|
||||||
|
<Breadcrumb.Item>执行任务</Breadcrumb.Item>
|
||||||
|
</Breadcrumb>
|
||||||
|
<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} 台主机`}/>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
style={{marginBottom: 24}}
|
||||||
|
icon={<PlusOutlined/>}
|
||||||
|
onClick={() => store.showHost = true}>从主机列表中选择</Button>
|
||||||
|
<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 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={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)
|
||||||
|
|
|
@ -41,4 +41,81 @@
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
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 React, { useState } from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
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 { ACEditor } from 'components';
|
||||||
import { http, cleanCommand } from 'libs';
|
import { http, cleanCommand } from 'libs';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
@ -76,12 +76,20 @@ export default observer(function () {
|
||||||
<Form.Item required name="name" label="模板名称">
|
<Form.Item required name="name" label="模板名称">
|
||||||
<Input placeholder="请输入模板名称"/>
|
<Input placeholder="请输入模板名称"/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item required label="模板内容">
|
<Form.Item required name="interpreter" label="脚本语言">
|
||||||
<ACEditor
|
<Radio.Group>
|
||||||
mode="sh"
|
<Radio.Button value="sh">Shell</Radio.Button>
|
||||||
value={body}
|
<Radio.Button value="python">Python</Radio.Button>
|
||||||
onChange={val => setBody(val)}
|
</Radio.Group>
|
||||||
height="300px"/>
|
</Form.Item>
|
||||||
|
<Form.Item required label="模板内容" shouldUpdate={(p, c) => p.interpreter !== c.interpreter}>
|
||||||
|
{({getFieldValue}) => (
|
||||||
|
<ACEditor
|
||||||
|
mode={getFieldValue('interpreter')}
|
||||||
|
value={body}
|
||||||
|
onChange={val => setBody(val)}
|
||||||
|
height="300px"/>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="desc" label="备注信息">
|
<Form.Item name="desc" label="备注信息">
|
||||||
<Input.TextArea placeholder="请输入模板备注信息"/>
|
<Input.TextArea placeholder="请输入模板备注信息"/>
|
||||||
|
|
|
@ -26,7 +26,7 @@ class Store {
|
||||||
.finally(() => this.isFetching = false)
|
.finally(() => this.isFetching = false)
|
||||||
};
|
};
|
||||||
|
|
||||||
showForm = (info = {}) => {
|
showForm = (info = {interpreter: 'sh'}) => {
|
||||||
this.formVisible = true;
|
this.formVisible = true;
|
||||||
this.record = info
|
this.record = info
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue