U 优化批量执行

pull/418/head
vapao 2021-12-02 00:22:04 +08:00
parent 155319e72d
commit df70fce54a
10 changed files with 429 additions and 320 deletions

View File

@ -53,7 +53,7 @@ class Job:
if not self.token:
with self.ssh:
return self.ssh.exec_command(self.command, self.env)
self.send('\x1b[36m### Executing ...\x1b[0m\r\n')
self.send('\r\33[K\x1b[36m### Executing ...\x1b[0m\r\n')
code = -1
try:
with self.ssh:

View File

@ -28,7 +28,8 @@ class ExecTemplate(models.Model, ModelMixin):
class ExecHistory(models.Model, ModelMixin):
digest = models.CharField(max_length=32, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
digest = models.CharField(max_length=32, db_index=True)
interpreter = models.CharField(max_length=20)
command = models.TextField()
host_ids = models.TextField()
@ -41,4 +42,4 @@ class ExecHistory(models.Model, ModelMixin):
class Meta:
db_table = 'exec_histories'
ordering = ('-id',)
ordering = ('-updated_at',)

View File

@ -78,12 +78,13 @@ def do_task(request):
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()
record = ExecHistory.objects.filter(user=request.user, digest=digest).first()
if record:
record.updated_at = human_datetime()
record.save()
else:
ExecHistory.objects.create(
user=request.user,
digest=digest,
interpreter=form.interpreter,
command=form.command,
@ -95,5 +96,5 @@ def do_task(request):
@auth('exec.task.do')
def get_histories(request):
records = ExecHistory.objects.all()
records = ExecHistory.objects.filter(user=request.user)
return json_response([x.to_view() for x in records])

View File

@ -19,7 +19,7 @@
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
"xterm": "^4.6.0",
"xterm-addon-fit": "^0.4.0"
"xterm-addon-fit": "^0.5.0"
},
"scripts": {
"start": "react-app-rewired start",

View File

@ -1,157 +0,0 @@
/**
* 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 { observer } from 'mobx-react';
import {
CheckCircleTwoTone,
LoadingOutlined,
WarningTwoTone,
FullscreenOutlined,
FullscreenExitOutlined,
CodeOutlined
} from '@ant-design/icons';
import { Modal, Collapse, Tooltip } from 'antd';
import OutView from './OutView';
import { X_TOKEN } from 'libs';
import styles from './index.module.less';
import store from './store';
@observer
class ExecConsole extends React.Component {
constructor(props) {
super(props);
this.lastOutputs = {};
this.socket = null;
this.terms = {};
this.outputs = {};
this.state = {
activeKey: Object.keys(store.outputs)[0],
isFullscreen: false
}
}
componentDidMount() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/exec/${store.token}/?x-token=${X_TOKEN}`);
this.socket.onopen = () => {
for (let key of Object.keys(store.outputs)) {
this.handleWrite(key, '\x1b[36m### Waiting for scheduling ...\x1b[0m\r\n')
}
this.socket.send('ok');
};
this.socket.onmessage = e => {
if (e.data === 'pong') {
this.socket.send('ping')
} else {
const {key, data, status} = JSON.parse(e.data);
if (status !== undefined) store.outputs[key].status = status;
if (data) {
this.handleWrite(key, data);
const tmp = data.trim();
if (tmp) this.lastOutputs[key] = tmp.split('\r\n').slice(-1)
}
}
};
this.socket.onerror = () => {
for (let key of Object.keys(store.outputs)) {
store.outputs[key]['status'] = 'websocket error'
if (this.terms[key]) {
this.terms[key].write('\x1b[31mWebsocket connection failed!\x1b[0m')
} else {
this.outputs[key] = '\x1b[31mWebsocket connection failed!\x1b[0m'
}
}
}
}
componentWillUnmount() {
this.socket.close();
store.isFullscreen = false;
}
handleWrite = (key, data) => {
if (this.terms[key]) {
this.terms[key].write(data)
} else if (this.outputs[key]) {
this.outputs[key] += data
} else {
this.outputs[key] = data
}
}
StatusExtra = (props) => (
<div style={{display: 'flex', alignItems: 'center'}}>
{props.status === -2 ? (
<LoadingOutlined style={{fontSize: 22, color: '#108ee9'}}/>
) : (
<>
<pre className={styles.header2}>{this.lastOutputs[props.id]}</pre>
{props.status === 0 ? (
<CheckCircleTwoTone style={{fontSize: 22}} twoToneColor="#52c41a"/>
) : (
<Tooltip title={`退出状态码:${props.status}`}>
<WarningTwoTone style={{fontSize: 22}} twoToneColor="red"/>
</Tooltip>
)}
</>
)}
<CodeOutlined style={{fontSize: 22, color: '#1890ff', marginLeft: 16}}
onClick={e => this.openTerminal(e, props.id)}/>
</div>
)
handleUpdate = (data) => {
this.setState(data, () => {
const key = this.state.activeKey;
if (key && this.terms[key]) setTimeout(this.terms[key].fit)
})
}
openTerminal = (e, key) => {
e.stopPropagation()
window.open(`/ssh?id=${key}`)
}
render() {
const {isFullscreen, activeKey} = this.state;
return (
<Modal
visible
width={isFullscreen ? '100%' : 1000}
title={[
<span key="1">执行控制台</span>,
<div key="2" className={styles.fullscreen} onClick={() => this.handleUpdate({isFullscreen: !isFullscreen})}>
{isFullscreen ? <FullscreenExitOutlined/> : <FullscreenOutlined/>}
</div>
]}
footer={null}
onCancel={this.props.onCancel}
onOk={this.handleSubmit}
maskClosable={false}>
<Collapse
accordion
className={styles.collapse}
activeKey={activeKey}
onChange={key => this.handleUpdate({activeKey: key})}>
{Object.entries(store.outputs).map(([key, item], index) => (
<Collapse.Panel
key={key}
header={<div className={styles.header1}>{item.title}</div>}
extra={<this.StatusExtra status={item.status} id={key}/>}>
<OutView
isFullscreen={isFullscreen}
getOutput={() => this.outputs[key]}
setTerm={term => this.terms[key] = term}/>
</Collapse.Panel>
))}
</Collapse>
</Modal>
)
}
}
export default ExecConsole

View File

@ -1,34 +0,0 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, { useEffect, useRef } from 'react';
import { FitAddon } from 'xterm-addon-fit';
import { Terminal } from 'xterm';
function OutView(props) {
const el = useRef()
useEffect(() => {
const fitPlugin = new FitAddon()
const term = new Terminal({disableStdin: true})
term.loadAddon(fitPlugin)
term.setOption('theme', {background: '#fff', foreground: '#000', selection: '#999'})
term.open(el.current)
const data = props.getOutput()
if (data) term.write(data)
term.fit = () => fitPlugin.fit()
props.setTerm(term)
fitPlugin.fit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div style={{padding: '8px 0 0 15px'}}>
<div ref={el}/>
</div>
)
}
export default OutView

View File

@ -0,0 +1,151 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, { useEffect, useRef, useState } from 'react';
import { observer } from 'mobx-react';
import { PageHeader } from 'antd';
import {
LoadingOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
CodeOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { FitAddon } from 'xterm-addon-fit';
import { Terminal } from 'xterm';
import style from './index.module.less';
import { X_TOKEN } from 'libs';
import store from './store';
let gCurrent;
function OutView(props) {
const el = useRef()
const [term, setTerm] = useState(new Terminal())
const [current, setCurrent] = useState(Object.keys(store.outputs)[0])
useEffect(() => {
store.tag = ''
gCurrent = current
const fitPlugin = new FitAddon()
term.setOption('disableStdin', false)
term.setOption('theme', {background: '#f0f0f0', foreground: '#000', selection: '#999', cursor: '#f0f0f0'})
term.loadAddon(fitPlugin)
term.open(el.current)
fitPlugin.fit()
term.write('WebSocket connecting ... ')
const resize = () => fitPlugin.fit();
window.addEventListener('resize', resize)
setTerm(term)
return () => window.removeEventListener('resize', resize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/exec/${store.token}/?x-token=${X_TOKEN}`);
socket.onopen = () => {
term.write('\r\x1b[K\x1b[36m### Waiting for scheduling ...\x1b[0m')
socket.send('ok');
}
socket.onmessage = e => {
if (e.data === 'pong') {
socket.send('ping')
} else {
_handleData(e.data)
}
}
socket.onclose = () => {
for (let key of Object.keys(store.outputs)) {
if (store.outputs[key].status === -2) {
store.outputs[key].status = -1
}
store.outputs[key].data += '\r\n\x1b[31mWebsocket connection failed!\x1b[0m'
term.write('\r\n\x1b[31mWebsocket connection failed!\x1b[0m')
}
}
return () => socket && socket.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function _handleData(message) {
const {key, data, status} = JSON.parse(message);
if (status !== undefined) {
store.outputs[key].status = status;
}
if (data) {
store.outputs[key].data += data
if (String(key) === gCurrent) term.write(data)
}
}
function handleSwitch(key) {
setCurrent(key)
gCurrent = key
term.clear()
term.write(store.outputs[key].data)
}
function openTerminal(key) {
window.open(`/ssh?id=${key}`)
}
const {tag, items, counter} = store
return (
<div className={style.output}>
<div className={style.side}>
<PageHeader onBack={props.onBack} title="执行详情"/>
<div className={style.tags}>
<div
className={`${style.item} ${tag === '0' ? style.pendingOn : style.pending}`}
onClick={() => store.updateTag('0')}>
<ClockCircleOutlined/>
<div>{counter['0']}</div>
</div>
<div
className={`${style.item} ${tag === '1' ? style.successOn : style.success}`}
onClick={() => store.updateTag('1')}>
<CheckCircleOutlined/>
<div>{counter['1']}</div>
</div>
<div
className={`${style.item} ${tag === '2' ? style.failOn : style.fail}`}
onClick={() => store.updateTag('2')}>
<ExclamationCircleOutlined/>
<div>{counter['2']}</div>
</div>
</div>
<div className={style.list}>
{items.map(([key, item]) => (
<div key={key} className={[style.item, key === current ? style.active : ''].join(' ')}
onClick={() => handleSwitch(key)}>
{item.status === -2 ? (
<LoadingOutlined style={{color: '#1890ff'}}/>
) : item.status === 0 ? (
<CheckCircleOutlined style={{color: '#52c41a'}}/>
) : (
<ExclamationCircleOutlined style={{color: 'red'}}/>
)}
<div className={style.text}>{item.title}</div>
</div>
))}
</div>
</div>
<div className={style.body}>
<div className={style.header}>
<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>
</div>
</div>
)
}
export default observer(OutView)

View File

@ -6,11 +6,11 @@
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react';
import { PlusOutlined, ThunderboltOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Form, Button, Card, Alert, Radio, Tooltip } from 'antd';
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 ExecConsole from './ExecConsole';
import Output from './Output';
import { http, cleanCommand } from 'libs';
import moment from 'moment';
import store from './store';
@ -55,8 +55,8 @@ function TaskIndex() {
<Breadcrumb.Item>批量执行</Breadcrumb.Item>
<Breadcrumb.Item>执行任务</Breadcrumb.Item>
</Breadcrumb>
<Card bodyStyle={{display: 'flex', padding: 0}}>
<Form layout="vertical" style={{padding: 24, width: '60%'}}>
<div className={style.index} hidden={store.showConsole}>
<Form layout="vertical" className={style.left}>
<Form.Item required label="目标主机">
{store.host_ids.length > 0 && (
<Alert style={{width: 200}} type="info" message={`已选择 ${store.host_ids.length} 台主机`}/>
@ -75,18 +75,20 @@ function TaskIndex() {
<Radio.Button value="sh">Shell</Radio.Button>
<Radio.Button value="python">Python</Radio.Button>
</Radio.Group>
<a href="https://spug.cc/docs/batch-exec" target="_blank" className={style.tips}>全局变量</a>
<ACEditor mode={interpreter} value={command} height="350px" width="100%" onChange={setCommand}/>
<a href="https://spug.cc/docs/batch-exec" target="_blank" rel="noopener noreferrer"
className={style.tips}>全局变量</a>
<ACEditor className={style.editor} mode={interpreter} value={command} 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.right}>
<div className={style.title}>
执行记录
<Tooltip title="每天自动清理保留最近50条记录。">
<Tooltip title="多次相同的执行记录将会合并展示,每天自动清理保留最近50条记录。">
<QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>
</Tooltip>
</div>
@ -101,10 +103,10 @@ function TaskIndex() {
))}
</div>
</div>
</Card>
</div>
{store.showTemplate &&
<TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
{store.showConsole && <ExecConsole onCancel={store.switchConsole}/>}
{store.showConsole && <Output onBack={store.switchConsole}/>}
<Selector
visible={store.showHost}
selectedRowKeys={[...store.host_ids]}

View File

@ -1,131 +1,239 @@
.collapse :global(.ant-collapse-content-box) {
padding: 0;
}
.fullscreen {
position: absolute;
top: 0;
right: 0;
display: block;
width: 56px;
height: 56px;
line-height: 56px;
text-align: center;
cursor: pointer;
color: rgba(0, 0, 0, .45);
margin-right: 56px;
}
.fullscreen:hover {
color: #000;
}
.header1 {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
font-weight: bold;
margin: 0;
}
.header2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 450px;
padding-right: 20px;
margin: 0;
}
pre {
margin: 0;
}
.tips {
position: absolute;
top: 10px;
left: 180px;
}
.hisBlock {
width: 40%;
.index {
display: flex;
flex-direction: column;
background-color: #fafafa;
padding: 24px;
overflow: auto;
height: calc(100vh - 218px);
background-color: #fff;
overflow: hidden;
.title {
font-weight: 500;
margin-bottom: 12px;
.left {
padding: 24px;
width: 60%;
.tips {
position: absolute;
top: 10px;
left: 180px;
}
.editor {
height: calc(100vh - 588px) !important;
}
}
.inner {
max-height: 700px;
overflow: auto;
}
.item {
.right {
width: 40%;
display: flex;
align-items: center;
border-radius: 2px;
padding: 8px 12px;
cursor: pointer;
margin-bottom: 12px;
flex-direction: column;
background-color: #fafafa;
padding: 24px 24px 0 24px;
.sh {
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
color: #fff;
.title {
font-weight: 500;
border-radius: 2px;
background-color: #1890ff;
margin-bottom: 12px;
}
.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 {
.inner {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 12px;
overflow: auto;
}
.desc {
color: #999;
}
}
.item {
display: flex;
align-items: center;
border-radius: 2px;
padding: 8px 12px;
cursor: pointer;
margin-bottom: 12px;
.item:hover {
border-color: #1890ff;
background-color: #e6f7ff;
.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;
}
}
}
.output {
display: flex;
background-color: #fff;
height: calc(100vh - 218px);
overflow: hidden;
.side {
display: flex;
flex-direction: column;
width: 300px;
border-right: 1px solid #dfdfdf;
.tags {
padding: 0 24px 24px;
display: flex;
justify-content: space-between;
.item {
width: 70px;
display: flex;
align-items: center;
justify-content: space-around;
border-radius: 35px;
padding: 2px 8px;
cursor: pointer;
background-color: #f3f3f3;
color: #666;
user-select: none;
}
.pendingOn {
background-color: #1890ff;
color: #fff;
}
.pending {
color: #1890ff;
}
.pending:hover {
background-color: #1890ff;
opacity: 0.7;
color: #fff;
}
.successOn {
background-color: #52c41a;
color: #fff;
}
.success {
color: #52c41a;
}
.success:hover {
background-color: #52c41a;
opacity: 0.7;
color: #fff;
}
.failOn {
background-color: red;
color: #fff;
}
.fail {
color: red;
}
.fail:hover {
background-color: red;
opacity: 0.6;
color: #fff;
}
}
.list {
flex: 1;
overflow: auto;
padding-bottom: 8px;
.item {
display: flex;
align-items: center;
padding: 8px 24px;
cursor: pointer;
&.active {
background: #e6f7ff;
}
:global(.anticon) {
margin-right: 4px;
}
.text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
}
.item:hover {
background: #e6f7ff;
}
}
}
.body {
display: flex;
flex-direction: column;
width: calc(100% - 300px);
padding: 22px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.icon {
font-size: 18px;
color: #1890ff;
cursor: pointer;
}
.title {
font-weight: 500;
}
}
.term {
flex: 1;
background-color: #f0f0f0;
padding: 8px 0 8px 12px;
border-radius: 2px;
}
}
}

View File

@ -3,17 +3,53 @@
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import { observable } from "mobx";
import { observable, computed } from "mobx";
import hostStore from 'pages/host/store';
class Store {
@observable outputs = {};
@observable tag = '';
@observable host_ids = [];
@observable token = null;
@observable showHost = false;
@observable showConsole = false;
@observable showTemplate = false;
@computed get items() {
const items = Object.entries(this.outputs)
if (this.tag === '') {
return items
} else if (this.tag === '0') {
return items.filter(([_, x]) => x.status === -2)
} else if (this.tag === '1') {
return items.filter(([_, x]) => x.status === 0)
} else {
return items.filter(([_, x]) => ![-2, 0].includes(x.status))
}
}
@computed get counter() {
const counter = {'0': 0, '1': 0, '2': 0}
for (let item of Object.values(this.outputs)) {
if (item.status === -2) {
counter['0'] += 1
} else if (item.status === 0) {
counter['1'] += 1
} else {
counter['2'] += 1
}
}
return counter
}
updateTag = (tag) => {
if (tag === this.tag) {
this.tag = ''
} else {
this.tag = tag
}
}
switchHost = () => {
this.showHost = !this.showHost;
};
@ -31,6 +67,7 @@ class Store {
const host = hostStore.idMap[id];
this.outputs[host.id] = {
title: `${host.name}(${host.hostname}:${host.port})`,
data: '',
status: -2
}
}