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']})(