mirror of https://github.com/openspug/spug
U web add ssh batch execution
parent
33ac2eff6e
commit
5a3222dd5b
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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'},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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),
|
||||
]
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
|
@ -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()
|
|
@ -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']})(
|
||||
|
|
Loading…
Reference in New Issue