diff --git a/spug_web/package.json b/spug_web/package.json
index 5f3c242..7d320c0 100644
--- a/spug_web/package.json
+++ b/spug_web/package.json
@@ -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",
diff --git a/spug_web/src/components/SHEditor.js b/spug_web/src/components/SHEditor.js
new file mode 100644
index 0000000..43f7eea
--- /dev/null
+++ b/spug_web/src/components/SHEditor.js
@@ -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 (
+
+ )
+}
\ No newline at end of file
diff --git a/spug_web/src/components/index.js b/spug_web/src/components/index.js
index dcc34c1..ccdbd7a 100644
--- a/spug_web/src/components/index.js
+++ b/spug_web/src/components/index.js
@@ -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,
}
\ No newline at end of file
diff --git a/spug_web/src/menus.js b/spug_web/src/menus.js
index e1f4b78..48d0277 100644
--- a/spug_web/src/menus.js
+++ b/spug_web/src/menus.js
@@ -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'},
]
},
{
diff --git a/spug_web/src/pages/exec/routes.js b/spug_web/src/pages/exec/routes.js
index 103a0bb..0a75e09 100644
--- a/spug_web/src/pages/exec/routes.js
+++ b/spug_web/src/pages/exec/routes.js
@@ -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),
]
\ No newline at end of file
diff --git a/spug_web/src/pages/exec/task/ExecConsole.js b/spug_web/src/pages/exec/task/ExecConsole.js
new file mode 100644
index 0000000..f8ee704
--- /dev/null
+++ b/spug_web/src/pages/exec/task/ExecConsole.js
@@ -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 (
+
+
{item['latest']}
+ {item['status'] === -2 ?
:
+ item['status'] === 0 ?
+
:
+
}
+
+ )
+ };
+
+ render() {
+ return (
+
+ }>
+ {Object.entries(store.outputs).map(([key, item], index) => (
+ {item['title']}{key}}
+ extra={this.genExtra(key)}>
+
+ {item['system']}
+ {item['info']}
+ this.elements[key] = ref} style={{color: '#ffa39e'}}>{item['error']}
+
+
+ ))}
+
+
+ )
+ }
+}
+
+export default ExecConsole
\ No newline at end of file
diff --git a/spug_web/src/pages/exec/task/HostSelector.js b/spug_web/src/pages/exec/task/HostSelector.js
new file mode 100644
index 0000000..52eafe8
--- /dev/null
+++ b/spug_web/src/pages/exec/task/HostSelector.js
@@ -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 (
+
+
+
+
+
+
+ store.f_name = e.target.value} placeholder="请输入"/>
+
+
+
+
+
+ item.id),
+ onChange: (_, selectedRows) => this.setState({selectedRows})
+ }}
+ dataSource={data}
+ loading={store.isFetching}
+ onRow={record => {
+ return {
+ onClick: () => this.handleClick(record)
+ }
+ }}
+ columns={this.columns}/>
+
+ )
+ }
+}
+
+export default HostSelector
\ No newline at end of file
diff --git a/spug_web/src/pages/exec/task/TemplateSelector.js b/spug_web/src/pages/exec/task/TemplateSelector.js
new file mode 100644
index 0000000..0c8b780
--- /dev/null
+++ b/spug_web/src/pages/exec/task/TemplateSelector.js
@@ -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 (
+
+
+
+
+
+
+ store.f_name = e.target.value} placeholder="请输入"/>
+
+
+
+
+
+ item.id),
+ type: 'radio',
+ onChange: (_, selectedRows) => this.setState({selectedRows})
+ }}
+ dataSource={data}
+ loading={store.isFetching}
+ onRow={record => {
+ return {
+ onClick: () => this.handleClick(record)
+ }
+ }}
+ columns={this.columns}/>
+
+ )
+ }
+}
+
+export default TemplateSelector
\ No newline at end of file
diff --git a/spug_web/src/pages/exec/task/index.js b/spug_web/src/pages/exec/task/index.js
new file mode 100644
index 0000000..6ad514f
--- /dev/null
+++ b/spug_web/src/pages/exec/task/index.js
@@ -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 (
+
+
+ {store.hosts.map(item => (
+ {item.name}({item.hostname}:{item.port})
+ ))}
+
+
+
+ this.setState({body})}/>
+
+
+
+
+
+
+ {store.showHost && store.hosts = hosts}/>}
+ {store.showTemplate && this.setState({body})}/>}
+ {store.showConsole && }
+
+ )
+ }
+}
+
+export default TaskIndex
\ No newline at end of file
diff --git a/spug_web/src/pages/exec/task/index.module.css b/spug_web/src/pages/exec/task/index.module.css
new file mode 100644
index 0000000..a62f56f
--- /dev/null
+++ b/spug_web/src/pages/exec/task/index.module.css
@@ -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;
+}
\ No newline at end of file
diff --git a/spug_web/src/pages/exec/task/store.js b/spug_web/src/pages/exec/task/store.js
new file mode 100644
index 0000000..3716d35
--- /dev/null
+++ b/spug_web/src/pages/exec/task/store.js
@@ -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()
\ No newline at end of file
diff --git a/spug_web/src/pages/exec/template/Form.js b/spug_web/src/pages/exec/template/Form.js
index 30ce4c5..3572c59 100644
--- a/spug_web/src/pages/exec/template/Form.js
+++ b/spug_web/src/pages/exec/template/Form.js
@@ -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 {
)}
- this.setState({body: val})}
- enableLiveAutocompletion={true}
- enableBasicAutocompletion={true}
- enableSnippets={true}
- height="300px"/>
+ this.setState({body: val})}
+ height="300px"/>
{getFieldDecorator('desc', {initialValue: info['desc']})(