U web add ssh batch execution

pull/22/head
雷二猛 2019-11-24 17:07:50 +08:00
parent 33ac2eff6e
commit 5a3222dd5b
12 changed files with 463 additions and 15 deletions

View File

@ -8,6 +8,7 @@
"axios": "^0.19.0",
"history": "^4.10.1",
"http-proxy-middleware": "^0.20.0",
"lodash": "^4.17.15",
"mobx": "^5.15.0",
"mobx-react": "^6.1.4",
"react": "^16.11.0",

View File

@ -0,0 +1,19 @@
import React from "react";
import Editor from 'react-ace';
import 'ace-builds/src-noconflict/ext-language_tools';
import 'ace-builds/src-noconflict/mode-sh';
import 'ace-builds/src-noconflict/theme-tomorrow';
import 'ace-builds/src-noconflict/snippets/sh';
export default function (props) {
return (
<Editor
mode="sh"
theme="tomorrow"
enableLiveAutocompletion={true}
enableBasicAutocompletion={true}
enableSnippets={true}
{...props}
/>
)
}

View File

@ -1,9 +1,11 @@
import StatisticsCard from './StatisticsCard';
import SearchForm from './SearchForm';
import LinkButton from './LinkButton';
import SHEditor from './SHEditor';
export {
StatisticsCard,
SearchForm,
LinkButton,
SHEditor,
}

View File

@ -3,7 +3,8 @@ export default [
{icon: 'cloud-server', title: '主机管理', path: '/host'},
{
icon: 'deployment-unit', title: '批量执行', child: [
{title: '执行模板', path: '/exec/template'},
{title: '执行任务', path: '/exec/task'},
{title: '模板管理', path: '/exec/template'},
]
},
{

View File

@ -1,7 +1,9 @@
import { makeRoute } from "../../libs/router";
import Template from './template';
import Task from './task';
export default [
makeRoute('/template', Template),
makeRoute('/task', Task),
]

View File

@ -0,0 +1,95 @@
import React from 'react';
import { observer } from 'mobx-react';
import { Modal, Collapse, Icon } from 'antd';
import styles from './index.module.css';
import store from './store';
@observer
class ExecConsole extends React.Component {
constructor(props) {
super(props);
this.socket = null;
this.elements = {};
this.state = {
data: {}
}
}
componentDidMount() {
this.socket = new WebSocket(`ws://localhost:8000/ws/exec/${store.token}/`);
this.socket.onopen = () => {
this.socket.send('ok');
for (let item of Object.values(store.outputs)) {
item['system'] += '### Waiting for schedule\n'
}
};
this.socket.onmessage = e => {
if (e.data === 'pong') {
this.socket.send('ping')
} else {
const {key, data, type, status} = JSON.parse(e.data);
if (status !== undefined) {
store.outputs[key]['status'] = status
} else if (data) {
store.outputs[key][type] += data;
store.outputs[key]['latest'] = data;
if (this.elements[key]) {
this.elements[key].scrollIntoView({behavior: 'smooth'})
}
}
}
}
}
componentWillUnmount() {
this.socket.close()
}
genExtra = (key) => {
const item = store.outputs[key];
return (
<div style={{display: 'flex', alignItems: 'center'}}>
<pre className={styles.header}>{item['latest']}</pre>
{item['status'] === -2 ? <Icon type="loading" style={{fontSize: 20, color: '#108ee9'}}/> :
item['status'] === 0 ?
<Icon type="check-circle" style={{fontSize: 20}} theme="twoTone" twoToneColor="#52c41a"/> :
<Icon type="warning" style={{fontSize: 20}} theme="twoTone" twoToneColor="red"/>}
</div>
)
};
render() {
return (
<Modal
visible
width={800}
title="执行控制台"
footer={null}
onCancel={this.props.onCancel}
onOk={this.handleSubmit}
maskClosable={false}>
<Collapse
accordion
defaultActiveKey={[0]}
className={styles.collapse}
expandIcon={({isActive}) => <Icon type="caret-right" style={{fontSize: 16}} rotate={isActive ? 90 : 0}/>}>
{Object.entries(store.outputs).map(([key, item], index) => (
<Collapse.Panel
key={index}
header={<b>{item['title']}{key}</b>}
extra={this.genExtra(key)}>
<pre className={styles.console}>
<pre style={{color: '#91d5ff'}}>{item['system']}</pre>
{item['info']}
<pre ref={ref => this.elements[key] = ref} style={{color: '#ffa39e'}}>{item['error']}</pre>
</pre>
</Collapse.Panel>
))}
</Collapse>
</Modal>
)
}
}
export default ExecConsole

View File

@ -0,0 +1,113 @@
import React from 'react';
import { observer } from 'mobx-react';
import { Modal, Table, Input, Button, Select } from 'antd';
import { SearchForm } from 'components';
import store from '../../host/store';
@observer
class HostSelector extends React.Component {
constructor(props) {
super(props);
this.state = {
selectedRows: []
}
}
componentDidMount() {
if (store.records.length === 0) {
store.fetchRecords()
}
}
handleClick = (record) => {
const {selectedRows} = this.state;
const index = selectedRows.indexOf(record);
if (index > -1) {
selectedRows.splice(index, 1)
} else {
selectedRows.push(record)
}
this.setState({selectedRows});
};
handleSubmit = () => {
this.props.onOk(this.state.selectedRows);
this.props.onCancel()
};
columns = [{
title: '序号',
key: 'series',
render: (_, __, index) => index + 1,
width: 80
}, {
title: '类别',
dataIndex: 'zone',
}, {
title: '名称',
dataIndex: 'name',
}, {
title: '主机',
dataIndex: 'hostname',
}, {
title: '端口',
dataIndex: 'port'
}, {
title: '备注',
dataIndex: 'desc',
ellipsis: true
}];
render() {
const {selectedRows} = this.state;
let data = store.records;
if (store.f_name) {
data = data.filter(item => item['name'].toLowerCase().includes(store.f_name.toLowerCase()))
}
if (store.f_zone) {
data = data.filter(item => item['zone'].toLowerCase().includes(store.f_zone.toLowerCase()))
}
return (
<Modal
visible
width={800}
title="选择执行主机"
onCancel={this.props.onCancel}
onOk={this.handleSubmit}
maskClosable={false}>
<SearchForm>
<SearchForm.Item span={8} title="主机类别">
<Select allowClear placeholder="请选择" value={store.f_zone} onChange={v => store.f_zone = v}>
{store.zones.map(item => (
<Select.Option value={item} key={item}>{item}</Select.Option>
))}
</Select>
</SearchForm.Item>
<SearchForm.Item span={8} title="主机别名">
<Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder="请输入"/>
</SearchForm.Item>
<SearchForm.Item span={8}>
<Button type="primary" icon="sync" onClick={store.fetchRecords}>刷新</Button>
</SearchForm.Item>
</SearchForm>
<Table
rowKey="id"
rowSelection={{
selectedRowKeys: selectedRows.map(item => item.id),
onChange: (_, selectedRows) => this.setState({selectedRows})
}}
dataSource={data}
loading={store.isFetching}
onRow={record => {
return {
onClick: () => this.handleClick(record)
}
}}
columns={this.columns}/>
</Modal>
)
}
}
export default HostSelector

View File

@ -0,0 +1,107 @@
import React from 'react';
import { observer } from 'mobx-react';
import { Modal, Table, Input, Button, Select } from 'antd';
import { SearchForm } from 'components';
import store from '../template/store';
@observer
class TemplateSelector extends React.Component {
constructor(props) {
super(props);
this.state = {
selectedRows: [],
}
}
componentDidMount() {
if (store.records.length === 0) {
store.fetchRecords()
}
}
handleClick = (record) => {
this.setState({selectedRows: [record]});
};
handleSubmit = () => {
if (this.state.selectedRows.length > 0) {
this.props.onOk(this.state.selectedRows[0].body)
}
this.props.onCancel()
};
columns = [{
title: '序号',
key: 'series',
render: (_, __, index) => index + 1,
width: 80
}, {
title: '类型',
dataIndex: 'type',
}, {
title: '名称',
dataIndex: 'name',
}, {
title: '内容',
dataIndex: 'body',
ellipsis: true
}, {
title: '备注',
dataIndex: 'desc',
ellipsis: true
}];
render() {
const {selectedRows} = this.state;
let data = store.records;
if (store.f_name) {
data = data.filter(item => item['name'].toLowerCase().includes(store.f_name.toLowerCase()))
}
if (store.f_type) {
data = data.filter(item => item['type'].toLowerCase().includes(store.f_type.toLowerCase()))
}
return (
<Modal
visible
width={800}
title="选择执行模板"
onCancel={this.props.onCancel}
onOk={this.handleSubmit}
maskClosable={false}>
<SearchForm>
<SearchForm.Item span={8} title="模板类别">
<Select allowClear placeholder="请选择" value={store.f_type} onChange={v => store.f_type = v}>
{store.types.map(item => (
<Select.Option value={item} key={item}>{item}</Select.Option>
))}
</Select>
</SearchForm.Item>
<SearchForm.Item span={8} title="模板名称">
<Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder="请输入"/>
</SearchForm.Item>
<SearchForm.Item span={8}>
<Button type="primary" icon="sync" onClick={store.fetchRecords}>刷新</Button>
</SearchForm.Item>
</SearchForm>
<Table
rowKey="id"
rowSelection={{
selectedRowKeys: selectedRows.map(item => item.id),
type: 'radio',
onChange: (_, selectedRows) => this.setState({selectedRows})
}}
dataSource={data}
loading={store.isFetching}
onRow={record => {
return {
onClick: () => this.handleClick(record)
}
}}
columns={this.columns}/>
</Modal>
)
}
}
export default TemplateSelector

View File

@ -0,0 +1,56 @@
import React from 'react';
import { observer } from 'mobx-react';
import { Card, Form, Button, Tag } from 'antd';
import { SHEditor } from 'components';
import HostSelector from './HostSelector';
import TemplateSelector from './TemplateSelector';
import ExecConsole from './ExecConsole';
import store from './store';
import http from 'libs/http';
@observer
class TaskIndex extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: false,
body: '',
}
}
handleSubmit = () => {
this.setState({loading: true});
const host_ids = store.hosts.map(item => item.id);
http.post('/api/exec/do/', {host_ids, command: this.state.body})
.then(store.switchConsole)
.finally(() => this.setState({loading: false}))
};
render() {
const {body, token} = this.state;
return (
<Card>
<Form>
<Form.Item label="执行主机">
{store.hosts.map(item => (
<Tag color="#108ee9" key={item.id}>{item.name}({item.hostname}:{item.port})</Tag>
))}
</Form.Item>
<Button icon="plus" onClick={store.switchHost}>从主机列表中选择</Button>
<Form.Item label="执行命令">
<SHEditor value={body} height="300px" onChange={body => this.setState({body})}/>
</Form.Item>
<Form.Item>
<Button icon="plus" onClick={store.switchTemplate}>从执行模版中选择</Button>
</Form.Item>
<Button icon="thunderbolt" type="primary" onClick={this.handleSubmit}>开始执行</Button>
</Form>
{store.showHost && <HostSelector onCancel={store.switchHost} onOk={hosts => store.hosts = hosts}/>}
{store.showTemplate && <TemplateSelector onCancel={store.switchTemplate} onOk={body => this.setState({body})}/>}
{store.showConsole && <ExecConsole token={token} onCancel={store.switchConsole}/>}
</Card>
)
}
}
export default TaskIndex

View File

@ -0,0 +1,20 @@
.collapse :global(.ant-collapse-content-box) {
padding: 0;
}
.console {
max-height: 300px;
padding: 10px 15px;
}
.header {
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
padding-right: 20px;
margin: 0
}
pre {
margin: 0;
}

View File

@ -0,0 +1,41 @@
import { observable } from "mobx";
class Store {
@observable outputs = {};
@observable hosts = [];
@observable token = null;
@observable showHost = false;
@observable showConsole = false;
@observable showTemplate = false;
switchHost = () => {
this.showHost = !this.showHost;
};
switchTemplate = () => {
this.showTemplate = !this.showTemplate
};
switchConsole = (token) => {
if (this.showConsole) {
this.showConsole = false;
this.outputs = {}
} else {
for (let item of this.hosts) {
const key = `${item.hostname}:${item.port}`;
this.outputs[key] = {
title: `${item.name}(${key})`,
system: '### Establishing communication\n',
info: '',
error: '',
latest: '',
status: -2
}
}
this.token = token;
this.showConsole = true
}
}
}
export default new Store()

View File

@ -1,11 +1,7 @@
import React from 'react';
import { observer } from 'mobx-react';
import { Modal, Form, Input, Select, Col, Button, message } from 'antd';
import Editor from 'react-ace';
import 'ace-builds/src-noconflict/ext-language_tools';
import 'ace-builds/src-noconflict/mode-sh';
import 'ace-builds/src-noconflict/theme-tomorrow';
import 'ace-builds/src-noconflict/snippets/sh';
import { SHEditor } from 'components';
import http from 'libs/http';
import store from './store';
@ -92,15 +88,10 @@ class ComForm extends React.Component {
)}
</Form.Item>
<Form.Item {...itemLayout} required label="模板内容">
<Editor
mode="sh"
theme="tomorrow"
value={this.state.body}
onChange={val => this.setState({body: val})}
enableLiveAutocompletion={true}
enableBasicAutocompletion={true}
enableSnippets={true}
height="300px"/>
<SHEditor
value={this.state.body}
onChange={val => this.setState({body: val})}
height="300px"/>
</Form.Item>
<Form.Item {...itemLayout} label="备注信息">
{getFieldDecorator('desc', {initialValue: info['desc']})(