From df70fce54af5a8c60ffbd8f4fb05844c3d920126 Mon Sep 17 00:00:00 2001 From: vapao Date: Thu, 2 Dec 2021 00:22:04 +0800 Subject: [PATCH] =?UTF-8?q?U=20=E4=BC=98=E5=8C=96=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spug_api/apps/exec/executors.py | 2 +- spug_api/apps/exec/models.py | 5 +- spug_api/apps/exec/views.py | 5 +- spug_web/package.json | 2 +- spug_web/src/pages/exec/task/ExecConsole.js | 157 --------- spug_web/src/pages/exec/task/OutView.js | 34 -- spug_web/src/pages/exec/task/Output.js | 151 ++++++++ spug_web/src/pages/exec/task/index.js | 22 +- .../src/pages/exec/task/index.module.less | 332 ++++++++++++------ spug_web/src/pages/exec/task/store.js | 39 +- 10 files changed, 429 insertions(+), 320 deletions(-) delete mode 100644 spug_web/src/pages/exec/task/ExecConsole.js delete mode 100644 spug_web/src/pages/exec/task/OutView.js create mode 100644 spug_web/src/pages/exec/task/Output.js diff --git a/spug_api/apps/exec/executors.py b/spug_api/apps/exec/executors.py index 6b1057a..be5739d 100644 --- a/spug_api/apps/exec/executors.py +++ b/spug_api/apps/exec/executors.py @@ -53,7 +53,7 @@ class Job: if not self.token: with self.ssh: 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 try: with self.ssh: diff --git a/spug_api/apps/exec/models.py b/spug_api/apps/exec/models.py index 01f4514..0f86076 100644 --- a/spug_api/apps/exec/models.py +++ b/spug_api/apps/exec/models.py @@ -28,7 +28,8 @@ class ExecTemplate(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) command = models.TextField() host_ids = models.TextField() @@ -41,4 +42,4 @@ class ExecHistory(models.Model, ModelMixin): class Meta: db_table = 'exec_histories' - ordering = ('-id',) + ordering = ('-updated_at',) diff --git a/spug_api/apps/exec/views.py b/spug_api/apps/exec/views.py index 8380e40..775f090 100644 --- a/spug_api/apps/exec/views.py +++ b/spug_api/apps/exec/views.py @@ -78,12 +78,13 @@ def do_task(request): host_ids = json.dumps(form.host_ids) tmp_str = f'{form.interpreter},{host_ids},{form.command}' 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: record.updated_at = human_datetime() record.save() else: ExecHistory.objects.create( + user=request.user, digest=digest, interpreter=form.interpreter, command=form.command, @@ -95,5 +96,5 @@ def do_task(request): @auth('exec.task.do') 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]) diff --git a/spug_web/package.json b/spug_web/package.json index 6fe9a30..8d0dca8 100644 --- a/spug_web/package.json +++ b/spug_web/package.json @@ -19,7 +19,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "3.4.3", "xterm": "^4.6.0", - "xterm-addon-fit": "^0.4.0" + "xterm-addon-fit": "^0.5.0" }, "scripts": { "start": "react-app-rewired start", diff --git a/spug_web/src/pages/exec/task/ExecConsole.js b/spug_web/src/pages/exec/task/ExecConsole.js deleted file mode 100644 index 5d4ee2d..0000000 --- a/spug_web/src/pages/exec/task/ExecConsole.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug - * Copyright (c) - * 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) => ( -
- {props.status === -2 ? ( - - ) : ( - <> -
{this.lastOutputs[props.id]}
- {props.status === 0 ? ( - - ) : ( - - - - )} - - )} - this.openTerminal(e, props.id)}/> -
- ) - - 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 ( - 执行控制台, -
this.handleUpdate({isFullscreen: !isFullscreen})}> - {isFullscreen ? : } -
- ]} - footer={null} - onCancel={this.props.onCancel} - onOk={this.handleSubmit} - maskClosable={false}> - this.handleUpdate({activeKey: key})}> - {Object.entries(store.outputs).map(([key, item], index) => ( - {item.title}} - extra={}> - this.outputs[key]} - setTerm={term => this.terms[key] = term}/> - - ))} - -
- ) - } -} - -export default ExecConsole diff --git a/spug_web/src/pages/exec/task/OutView.js b/spug_web/src/pages/exec/task/OutView.js deleted file mode 100644 index 17c2c39..0000000 --- a/spug_web/src/pages/exec/task/OutView.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug - * Copyright (c) - * 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 ( -
-
-
- ) -} - -export default OutView \ No newline at end of file diff --git a/spug_web/src/pages/exec/task/Output.js b/spug_web/src/pages/exec/task/Output.js new file mode 100644 index 0000000..c9548b1 --- /dev/null +++ b/spug_web/src/pages/exec/task/Output.js @@ -0,0 +1,151 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * 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 ( +
+
+ +
+
store.updateTag('0')}> + +
{counter['0']}
+
+
store.updateTag('1')}> + +
{counter['1']}
+
+
store.updateTag('2')}> + +
{counter['2']}
+
+
+ +
+ {items.map(([key, item]) => ( +
handleSwitch(key)}> + {item.status === -2 ? ( + + ) : item.status === 0 ? ( + + ) : ( + + )} +
{item.title}
+
+ ))} +
+
+
+
+
{store.outputs[current].title}
+ openTerminal(current)}/> +
+
+
+
+
+
+ ) +} + +export default observer(OutView) \ 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 index 0e6afb1..eeaa315 100644 --- a/spug_web/src/pages/exec/task/index.js +++ b/spug_web/src/pages/exec/task/index.js @@ -6,11 +6,11 @@ import React, { useState, useEffect } from 'react'; import { observer } from 'mobx-react'; 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 Selector from 'pages/host/Selector'; import TemplateSelector from './TemplateSelector'; -import ExecConsole from './ExecConsole'; +import Output from './Output'; import { http, cleanCommand } from 'libs'; import moment from 'moment'; import store from './store'; @@ -55,8 +55,8 @@ function TaskIndex() { 批量执行 执行任务 - -
+
{store.showTemplate && } - {store.showConsole && } + {store.showConsole && } * Released under the AGPL-3.0 License. */ -import { observable } from "mobx"; +import { observable, computed } from "mobx"; import hostStore from 'pages/host/store'; class Store { @observable outputs = {}; + @observable tag = ''; @observable host_ids = []; @observable token = null; @observable showHost = false; @observable showConsole = 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 = () => { this.showHost = !this.showHost; }; @@ -31,6 +67,7 @@ class Store { const host = hostStore.idMap[id]; this.outputs[host.id] = { title: `${host.name}(${host.hostname}:${host.port})`, + data: '', status: -2 } }