mirror of https://github.com/openspug/spug
U 优化批量执行
parent
155319e72d
commit
df70fce54a
|
@ -53,7 +53,7 @@ class Job:
|
||||||
if not self.token:
|
if not self.token:
|
||||||
with self.ssh:
|
with self.ssh:
|
||||||
return self.ssh.exec_command(self.command, self.env)
|
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
|
code = -1
|
||||||
try:
|
try:
|
||||||
with self.ssh:
|
with self.ssh:
|
||||||
|
|
|
@ -28,7 +28,8 @@ class ExecTemplate(models.Model, ModelMixin):
|
||||||
|
|
||||||
|
|
||||||
class ExecHistory(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)
|
interpreter = models.CharField(max_length=20)
|
||||||
command = models.TextField()
|
command = models.TextField()
|
||||||
host_ids = models.TextField()
|
host_ids = models.TextField()
|
||||||
|
@ -41,4 +42,4 @@ class ExecHistory(models.Model, ModelMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'exec_histories'
|
db_table = 'exec_histories'
|
||||||
ordering = ('-id',)
|
ordering = ('-updated_at',)
|
||||||
|
|
|
@ -78,12 +78,13 @@ def do_task(request):
|
||||||
host_ids = json.dumps(form.host_ids)
|
host_ids = json.dumps(form.host_ids)
|
||||||
tmp_str = f'{form.interpreter},{host_ids},{form.command}'
|
tmp_str = f'{form.interpreter},{host_ids},{form.command}'
|
||||||
digest = hashlib.md5(tmp_str.encode()).hexdigest()
|
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:
|
if record:
|
||||||
record.updated_at = human_datetime()
|
record.updated_at = human_datetime()
|
||||||
record.save()
|
record.save()
|
||||||
else:
|
else:
|
||||||
ExecHistory.objects.create(
|
ExecHistory.objects.create(
|
||||||
|
user=request.user,
|
||||||
digest=digest,
|
digest=digest,
|
||||||
interpreter=form.interpreter,
|
interpreter=form.interpreter,
|
||||||
command=form.command,
|
command=form.command,
|
||||||
|
@ -95,5 +96,5 @@ def do_task(request):
|
||||||
|
|
||||||
@auth('exec.task.do')
|
@auth('exec.task.do')
|
||||||
def get_histories(request):
|
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])
|
return json_response([x.to_view() for x in records])
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-scripts": "3.4.3",
|
"react-scripts": "3.4.3",
|
||||||
"xterm": "^4.6.0",
|
"xterm": "^4.6.0",
|
||||||
"xterm-addon-fit": "^0.4.0"
|
"xterm-addon-fit": "^0.5.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-app-rewired start",
|
"start": "react-app-rewired start",
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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)
|
|
@ -6,11 +6,11 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { PlusOutlined, ThunderboltOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
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 { 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 Output from './Output';
|
||||||
import { http, cleanCommand } from 'libs';
|
import { http, cleanCommand } from 'libs';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
@ -55,8 +55,8 @@ function TaskIndex() {
|
||||||
<Breadcrumb.Item>批量执行</Breadcrumb.Item>
|
<Breadcrumb.Item>批量执行</Breadcrumb.Item>
|
||||||
<Breadcrumb.Item>执行任务</Breadcrumb.Item>
|
<Breadcrumb.Item>执行任务</Breadcrumb.Item>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<Card bodyStyle={{display: 'flex', padding: 0}}>
|
<div className={style.index} hidden={store.showConsole}>
|
||||||
<Form layout="vertical" style={{padding: 24, width: '60%'}}>
|
<Form layout="vertical" className={style.left}>
|
||||||
<Form.Item required label="目标主机">
|
<Form.Item required label="目标主机">
|
||||||
{store.host_ids.length > 0 && (
|
{store.host_ids.length > 0 && (
|
||||||
<Alert style={{width: 200}} type="info" message={`已选择 ${store.host_ids.length} 台主机`}/>
|
<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="sh">Shell</Radio.Button>
|
||||||
<Radio.Button value="python">Python</Radio.Button>
|
<Radio.Button value="python">Python</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
<a href="https://spug.cc/docs/batch-exec" target="_blank" className={style.tips}>全局变量</a>
|
<a href="https://spug.cc/docs/batch-exec" target="_blank" rel="noopener noreferrer"
|
||||||
<ACEditor mode={interpreter} value={command} height="350px" width="100%" onChange={setCommand}/>
|
className={style.tips}>全局变量</a>
|
||||||
|
<ACEditor className={style.editor} mode={interpreter} value={command} width="100%" onChange={setCommand}/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button>
|
<Button icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button loading={loading} icon={<ThunderboltOutlined/>} type="primary" onClick={handleSubmit}>开始执行</Button>
|
<Button loading={loading} icon={<ThunderboltOutlined/>} type="primary" onClick={handleSubmit}>开始执行</Button>
|
||||||
</Form>
|
</Form>
|
||||||
<div className={style.hisBlock}>
|
|
||||||
|
<div className={style.right}>
|
||||||
<div className={style.title}>
|
<div className={style.title}>
|
||||||
执行记录
|
执行记录
|
||||||
<Tooltip title="每天自动清理,保留最近50条记录。">
|
<Tooltip title="多次相同的执行记录将会合并展示,每天自动清理,保留最近50条记录。">
|
||||||
<QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>
|
<QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
@ -101,10 +103,10 @@ function TaskIndex() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
{store.showTemplate &&
|
{store.showTemplate &&
|
||||||
<TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
|
<TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
|
||||||
{store.showConsole && <ExecConsole onCancel={store.switchConsole}/>}
|
{store.showConsole && <Output onBack={store.switchConsole}/>}
|
||||||
<Selector
|
<Selector
|
||||||
visible={store.showHost}
|
visible={store.showHost}
|
||||||
selectedRowKeys={[...store.host_ids]}
|
selectedRowKeys={[...store.host_ids]}
|
||||||
|
|
|
@ -1,61 +1,30 @@
|
||||||
.collapse :global(.ant-collapse-content-box) {
|
.index {
|
||||||
padding: 0;
|
display: flex;
|
||||||
}
|
height: calc(100vh - 218px);
|
||||||
|
background-color: #fff;
|
||||||
.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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 400px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header2 {
|
.left {
|
||||||
overflow: hidden;
|
padding: 24px;
|
||||||
text-overflow: ellipsis;
|
width: 60%;
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 450px;
|
|
||||||
padding-right: 20px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
.tips {
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 180px;
|
left: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hisBlock {
|
.editor {
|
||||||
|
height: calc(100vh - 588px) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
width: 40%;
|
width: 40%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
padding: 24px;
|
padding: 24px 24px 0 24px;
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
@ -63,7 +32,7 @@ pre {
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner {
|
.inner {
|
||||||
max-height: 700px;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,7 +73,7 @@ pre {
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background-color:#dfdfdf;
|
background-color: #dfdfdf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command {
|
.command {
|
||||||
|
@ -124,8 +93,147 @@ pre {
|
||||||
border-color: #1890ff;
|
border-color: #1890ff;
|
||||||
background-color: #e6f7ff;
|
background-color: #e6f7ff;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.output {
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -3,17 +3,53 @@
|
||||||
* 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 { observable } from "mobx";
|
import { observable, computed } from "mobx";
|
||||||
import hostStore from 'pages/host/store';
|
import hostStore from 'pages/host/store';
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
@observable outputs = {};
|
@observable outputs = {};
|
||||||
|
@observable tag = '';
|
||||||
@observable host_ids = [];
|
@observable host_ids = [];
|
||||||
@observable token = null;
|
@observable token = null;
|
||||||
@observable showHost = false;
|
@observable showHost = false;
|
||||||
@observable showConsole = false;
|
@observable showConsole = false;
|
||||||
@observable showTemplate = 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 = () => {
|
switchHost = () => {
|
||||||
this.showHost = !this.showHost;
|
this.showHost = !this.showHost;
|
||||||
};
|
};
|
||||||
|
@ -31,6 +67,7 @@ class Store {
|
||||||
const host = hostStore.idMap[id];
|
const host = hostStore.idMap[id];
|
||||||
this.outputs[host.id] = {
|
this.outputs[host.id] = {
|
||||||
title: `${host.name}(${host.hostname}:${host.port})`,
|
title: `${host.name}(${host.hostname}:${host.port})`,
|
||||||
|
data: '',
|
||||||
status: -2
|
status: -2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue