mirror of https://github.com/openspug/spug
U 优化批量执行
parent
155319e72d
commit
df70fce54a
|
@ -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:
|
||||
|
|
|
@ -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',)
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,157 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* 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) => (
|
||||
<div style={{display: 'flex', alignItems: 'center'}}>
|
||||
{props.status === -2 ? (
|
||||
<LoadingOutlined style={{fontSize: 22, color: '#108ee9'}}/>
|
||||
) : (
|
||||
<>
|
||||
<pre className={styles.header2}>{this.lastOutputs[props.id]}</pre>
|
||||
{props.status === 0 ? (
|
||||
<CheckCircleTwoTone style={{fontSize: 22}} twoToneColor="#52c41a"/>
|
||||
) : (
|
||||
<Tooltip title={`退出状态码:${props.status}`}>
|
||||
<WarningTwoTone style={{fontSize: 22}} twoToneColor="red"/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<CodeOutlined style={{fontSize: 22, color: '#1890ff', marginLeft: 16}}
|
||||
onClick={e => this.openTerminal(e, props.id)}/>
|
||||
</div>
|
||||
)
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
visible
|
||||
width={isFullscreen ? '100%' : 1000}
|
||||
title={[
|
||||
<span key="1">执行控制台</span>,
|
||||
<div key="2" className={styles.fullscreen} onClick={() => this.handleUpdate({isFullscreen: !isFullscreen})}>
|
||||
{isFullscreen ? <FullscreenExitOutlined/> : <FullscreenOutlined/>}
|
||||
</div>
|
||||
]}
|
||||
footer={null}
|
||||
onCancel={this.props.onCancel}
|
||||
onOk={this.handleSubmit}
|
||||
maskClosable={false}>
|
||||
<Collapse
|
||||
accordion
|
||||
className={styles.collapse}
|
||||
activeKey={activeKey}
|
||||
onChange={key => this.handleUpdate({activeKey: key})}>
|
||||
{Object.entries(store.outputs).map(([key, item], index) => (
|
||||
<Collapse.Panel
|
||||
key={key}
|
||||
header={<div className={styles.header1}>{item.title}</div>}
|
||||
extra={<this.StatusExtra status={item.status} id={key}/>}>
|
||||
<OutView
|
||||
isFullscreen={isFullscreen}
|
||||
getOutput={() => this.outputs[key]}
|
||||
setTerm={term => this.terms[key] = term}/>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ExecConsole
|
|
@ -1,34 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* 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 (
|
||||
<div style={{padding: '8px 0 0 15px'}}>
|
||||
<div ref={el}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OutView
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* 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 (
|
||||
<div className={style.output}>
|
||||
<div className={style.side}>
|
||||
<PageHeader onBack={props.onBack} title="执行详情"/>
|
||||
<div className={style.tags}>
|
||||
<div
|
||||
className={`${style.item} ${tag === '0' ? style.pendingOn : style.pending}`}
|
||||
onClick={() => store.updateTag('0')}>
|
||||
<ClockCircleOutlined/>
|
||||
<div>{counter['0']}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${style.item} ${tag === '1' ? style.successOn : style.success}`}
|
||||
onClick={() => store.updateTag('1')}>
|
||||
<CheckCircleOutlined/>
|
||||
<div>{counter['1']}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${style.item} ${tag === '2' ? style.failOn : style.fail}`}
|
||||
onClick={() => store.updateTag('2')}>
|
||||
<ExclamationCircleOutlined/>
|
||||
<div>{counter['2']}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.list}>
|
||||
{items.map(([key, item]) => (
|
||||
<div key={key} className={[style.item, key === current ? style.active : ''].join(' ')}
|
||||
onClick={() => handleSwitch(key)}>
|
||||
{item.status === -2 ? (
|
||||
<LoadingOutlined style={{color: '#1890ff'}}/>
|
||||
) : item.status === 0 ? (
|
||||
<CheckCircleOutlined style={{color: '#52c41a'}}/>
|
||||
) : (
|
||||
<ExclamationCircleOutlined style={{color: 'red'}}/>
|
||||
)}
|
||||
<div className={style.text}>{item.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.body}>
|
||||
<div className={style.header}>
|
||||
<div className={style.title}>{store.outputs[current].title}</div>
|
||||
<CodeOutlined className={style.icon} onClick={() => openTerminal(current)}/>
|
||||
</div>
|
||||
<div className={style.term}>
|
||||
<div ref={el} style={{width: '100%'}}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(OutView)
|
|
@ -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() {
|
|||
<Breadcrumb.Item>批量执行</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>执行任务</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
<Card bodyStyle={{display: 'flex', padding: 0}}>
|
||||
<Form layout="vertical" style={{padding: 24, width: '60%'}}>
|
||||
<div className={style.index} hidden={store.showConsole}>
|
||||
<Form layout="vertical" className={style.left}>
|
||||
<Form.Item required label="目标主机">
|
||||
{store.host_ids.length > 0 && (
|
||||
<Alert style={{width: 200}} type="info" message={`已选择 ${store.host_ids.length} 台主机`}/>
|
||||
|
@ -75,18 +75,20 @@ function TaskIndex() {
|
|||
<Radio.Button value="sh">Shell</Radio.Button>
|
||||
<Radio.Button value="python">Python</Radio.Button>
|
||||
</Radio.Group>
|
||||
<a href="https://spug.cc/docs/batch-exec" target="_blank" className={style.tips}>全局变量</a>
|
||||
<ACEditor mode={interpreter} value={command} height="350px" width="100%" onChange={setCommand}/>
|
||||
<a href="https://spug.cc/docs/batch-exec" target="_blank" rel="noopener noreferrer"
|
||||
className={style.tips}>全局变量</a>
|
||||
<ACEditor className={style.editor} mode={interpreter} value={command} width="100%" onChange={setCommand}/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button>
|
||||
</Form.Item>
|
||||
<Button loading={loading} icon={<ThunderboltOutlined/>} type="primary" onClick={handleSubmit}>开始执行</Button>
|
||||
</Form>
|
||||
<div className={style.hisBlock}>
|
||||
|
||||
<div className={style.right}>
|
||||
<div className={style.title}>
|
||||
执行记录
|
||||
<Tooltip title="每天自动清理,保留最近50条记录。">
|
||||
<Tooltip title="多次相同的执行记录将会合并展示,每天自动清理,保留最近50条记录。">
|
||||
<QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
@ -101,10 +103,10 @@ function TaskIndex() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{store.showTemplate &&
|
||||
<TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}
|
||||
{store.showConsole && <ExecConsole onCancel={store.switchConsole}/>}
|
||||
{store.showConsole && <Output onBack={store.switchConsole}/>}
|
||||
<Selector
|
||||
visible={store.showHost}
|
||||
selectedRowKeys={[...store.host_ids]}
|
||||
|
|
|
@ -1,131 +1,239 @@
|
|||
.collapse :global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fullscreen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, .45);
|
||||
margin-right: 56px;
|
||||
}
|
||||
|
||||
.fullscreen:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.header1 {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 450px;
|
||||
padding-right: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tips {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 180px;
|
||||
}
|
||||
|
||||
.hisBlock {
|
||||
width: 40%;
|
||||
.index {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fafafa;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
height: calc(100vh - 218px);
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
.left {
|
||||
padding: 24px;
|
||||
width: 60%;
|
||||
|
||||
.tips {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 180px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
height: calc(100vh - 588px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-height: 700px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
.right {
|
||||
width: 40%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 2px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
flex-direction: column;
|
||||
background-color: #fafafa;
|
||||
padding: 24px 24px 0 24px;
|
||||
|
||||
.sh {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
.title {
|
||||
font-weight: 500;
|
||||
border-radius: 2px;
|
||||
background-color: #1890ff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.python {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-weight: 500;
|
||||
border-radius: 2px;
|
||||
background-color: #dca900;
|
||||
}
|
||||
|
||||
.number {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
margin-left: 12px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
background-color:#dfdfdf;
|
||||
}
|
||||
|
||||
.command {
|
||||
.inner {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 2px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.item:hover {
|
||||
border-color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
.sh {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
border-radius: 2px;
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
.python {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-weight: 500;
|
||||
border-radius: 2px;
|
||||
background-color: #dca900;
|
||||
}
|
||||
|
||||
.number {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
margin-left: 12px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
background-color: #dfdfdf;
|
||||
}
|
||||
|
||||
.command {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
border-color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.output {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
height: calc(100vh - 218px);
|
||||
overflow: hidden;
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
border-right: 1px solid #dfdfdf;
|
||||
|
||||
.tags {
|
||||
padding: 0 24px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.item {
|
||||
width: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
border-radius: 35px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
background-color: #f3f3f3;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pendingOn {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pending {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.pending:hover {
|
||||
background-color: #1890ff;
|
||||
opacity: 0.7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.successOn {
|
||||
background-color: #52c41a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.success:hover {
|
||||
background-color: #52c41a;
|
||||
opacity: 0.7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.failOn {
|
||||
background-color: red;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fail {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.fail:hover {
|
||||
background-color: red;
|
||||
opacity: 0.6;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 8px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 24px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
:global(.anticon) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(100% - 300px);
|
||||
padding: 22px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.term {
|
||||
flex: 1;
|
||||
background-color: #f0f0f0;
|
||||
padding: 8px 0 8px 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,17 +3,53 @@
|
|||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue