upgrade web terminal

pull/330/head
vapao 2021-04-08 23:35:07 +08:00
parent 90537943d6
commit 1883dba226
7 changed files with 263 additions and 138 deletions

View File

@ -7,14 +7,14 @@ from libs import json_response, JsonParser, Argument
from apps.host.models import Group
def fetch_children(data):
def fetch_children(data, with_hosts):
if data:
sub_data = dict()
for item in Group.objects.filter(parent_id__in=data.keys()):
tmp = item.to_view()
tmp = item.to_view(with_hosts)
sub_data[item.id] = tmp
data[item.parent_id]['children'].append(tmp)
return fetch_children(sub_data)
return fetch_children(sub_data, with_hosts)
def merge_children(data, prefix, childes):
@ -22,7 +22,7 @@ def merge_children(data, prefix, childes):
for item in childes:
name = f'{prefix}{item["title"]}'
item['name'] = name
if item['children']:
if item.get('children'):
merge_children(data, name, item['children'])
else:
data[item['key']] = name
@ -30,10 +30,11 @@ def merge_children(data, prefix, childes):
class GroupView(View):
def get(self, request):
with_hosts = request.GET.get('with_hosts')
data, data2 = dict(), dict()
for item in Group.objects.filter(parent_id=0):
data[item.id] = item.to_view()
fetch_children(data)
data[item.id] = item.to_view(with_hosts)
fetch_children(data, with_hosts)
if not data:
grp = Group.objects.create(name='Default', sort_id=1)
data[grp.id] = grp.to_view()

View File

@ -48,13 +48,14 @@ class Group(models.Model, ModelMixin):
sort_id = models.IntegerField(default=0)
hosts = models.ManyToManyField(Host, related_name='groups')
def to_view(self):
return {
'key': self.id,
'value': self.id,
'title': self.name,
'children': []
}
def to_view(self, with_hosts=False):
response = dict(key=self.id, value=self.id, title=self.name, children=[])
if with_hosts:
def make_item(x):
return dict(title=x.name, key=f'{self.id}_{x.id}', id=x.id, isLeaf=True)
response['children'] = [make_item(x) for x in self.hosts.all()]
return response
class Meta:
db_table = 'host_groups'

View File

@ -15,14 +15,13 @@ import {
} from '@ant-design/icons';
import { http, uniqueId, X_TOKEN } from 'libs';
import lds from 'lodash';
import styles from './index.module.css'
import styles from './index.module.less'
class FileManager extends React.Component {
constructor(props) {
super(props);
this.input = null;
this.id = props.id;
this.state = {
fetching: false,
showDot: false,
@ -92,7 +91,7 @@ class FileManager extends React.Component {
this.setState({fetching: true});
pwd = pwd || this.state.pwd;
const path = '/' + pwd.join('/');
http.get('/api/file/', {params: {id: this.id, path}})
http.get('/api/file/', {params: {id: this.props.id, path}})
.then(res => {
const objects = lds.orderBy(res, [this._kindSort, 'name'], ['desc', 'asc']);
this.setState({objects, pwd})
@ -154,7 +153,7 @@ class FileManager extends React.Component {
handleDownload = (name) => {
const file = `/${this.state.pwd.join('/')}/${name}`;
const link = document.createElement('a');
link.href = `/api/file/object/?id=${this.id}&file=${file}&x-token=${X_TOKEN}`;
link.href = `/api/file/object/?id=${this.props.id}&file=${file}&x-token=${X_TOKEN}`;
document.body.appendChild(link);
const evt = document.createEvent("MouseEvents");
evt.initEvent("click", false, false);
@ -169,7 +168,7 @@ class FileManager extends React.Component {
title: '删除文件确认',
content: `确认删除文件:${file} ?`,
onOk: () => {
return http.delete('/api/file/object/', {params: {id: this.id, file}})
return http.delete('/api/file/object/', {params: {id: this.props.id, file}})
.then(() => {
message.success('删除成功');
this.fetchFiles()

View File

@ -0,0 +1,67 @@
/**
* 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, useState, useRef } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { X_TOKEN } from 'libs';
import 'xterm/css/xterm.css';
import styles from './index.module.less';
function WebSSH(props) {
const container = useRef();
const [term] = useState(new Terminal());
useEffect(() => {
const fitPlugin = new FitAddon();
term.loadAddon(fitPlugin);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/ssh/${props.id}/?x-token=${X_TOKEN}`);
socket.onmessage = e => _read_as_text(e.data);
socket.onopen = () => {
term.open(container.current);
term.focus();
fitPlugin.fit();
};
socket.onclose = e => {
if (e.code === 3333) {
window.location.href = "about:blank";
window.close()
} else {
setTimeout(() => term.write('\r\nConnection is closed.\r\n'), 200)
}
};
term.onData(data => socket.send(JSON.stringify({data})));
term.onResize(({cols, rows}) => socket.send(JSON.stringify({resize: [cols, rows]})));
const resize = () => fitPlugin.fit();
window.addEventListener('resize', resize)
return () => {
window.removeEventListener('resize', resize);
if (socket) socket.close()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (String(props.id) === props.activeId) {
setTimeout(() => term.focus())
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.activeId])
function _read_as_text(data) {
const reader = new window.FileReader();
reader.onload = () => term.write(reader.result);
reader.readAsText(data, 'utf-8')
}
return (
<div className={styles.terminal} ref={container}/>
)
}
export default WebSSH

View File

@ -3,98 +3,120 @@
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React from 'react';
import { FolderOpenOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { AuthDiv } from 'components';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import React, { useEffect, useState } from 'react';
import { observer } from 'mobx-react';
import { Tabs, Tree, Button, Spin } from 'antd';
import { FolderOutlined, FolderOpenOutlined, CloudServerOutlined } from '@ant-design/icons';
import Terminal from './Terminal';
import FileManager from './FileManager';
import { http, X_TOKEN } from 'libs';
import 'xterm/css/xterm.css';
import styles from './index.module.css';
import { http } from 'libs';
import styles from './index.module.less';
import LogoSpugText from 'layout/logo-spug-txt.png';
import lds from 'lodash';
class WebSSH extends React.Component {
constructor(props) {
super(props);
this.id = props.match.params.id;
this.socket = null;
this.term = new Terminal();
this.container = null;
this.input = null;
this.state = {
visible: false,
uploading: false,
managerDisabled: true,
host: {},
percent: 0
function WebSSH(props) {
const [visible, setVisible] = useState(false);
const [fetching, setFetching] = useState(true);
const [treeData, setTreeData] = useState([]);
const [hosts, setHosts] = useState([]);
const [activeId, setActiveId] = useState();
useEffect(() => {
window.addEventListener('beforeunload', leaveTips)
http.get('/api/host/group/?with_hosts=1')
.then(res => setTreeData(res.treeData))
.finally(() => setFetching(false))
return () => window.removeEventListener('beforeunload', leaveTips)
}, [])
function leaveTips(e) {
e.returnValue = '确定要离开页面?'
}
function handleSelect(e) {
if (e.nativeEvent.detail > 1 && e.node.isLeaf) {
if (!lds.find(hosts, x => x.id === e.node.id)) {
hosts.push(e.node);
setHosts(lds.cloneDeep(hosts))
}
setActiveId(String(e.node.id))
}
}
componentDidMount() {
this._fetch();
const fitPlugin = new FitAddon();
this.term.loadAddon(fitPlugin);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/ssh/${this.id}/?x-token=${X_TOKEN}`);
this.socket.onmessage = e => this._read_as_text(e.data);
this.socket.onopen = () => {
this.term.open(this.container);
this.term.focus();
fitPlugin.fit();
};
this.socket.onclose = e => {
if (e.code === 3333) {
window.location.href = "about:blank";
window.close()
} else {
setTimeout(() => this.term.write('\r\nConnection is closed.\r\n'), 200)
function handleRemove(key, action) {
if (action === 'remove') {
const index = lds.findIndex(hosts, x => String(x.id) === key);
if (index !== -1) {
hosts.splice(index, 1);
setHosts(lds.cloneDeep(hosts));
if (hosts.length > index) {
setActiveId(String(hosts[index].id))
} else if (hosts.length) {
setActiveId(String(hosts[index - 1].id))
}
}
};
this.term.onData(data => this.socket.send(JSON.stringify({data})));
this.term.onResize(({cols, rows}) => {
this.socket.send(JSON.stringify({resize: [cols, rows]}))
});
window.onresize = () => fitPlugin.fit()
}
}
_read_as_text = (data) => {
const reader = new window.FileReader();
reader.onload = () => this.term.write(reader.result);
reader.readAsText(data, 'utf-8')
};
function renderIcon(node) {
if (node.isLeaf) {
return <CloudServerOutlined/>
} else if (node.expanded) {
return <FolderOpenOutlined/>
} else {
return <FolderOutlined/>
}
}
handleShow = () => {
this.setState({visible: !this.state.visible})
};
const spug_web_terminal =
' __ __ _ __\n' +
' _____ ____ __ __ ____ _ _ __ ___ / /_ / /_ ___ _____ ____ ___ (_)____ ____ _ / /\n' +
' / ___// __ \\ / / / // __ `/ | | /| / // _ \\ / __ \\ / __// _ \\ / ___// __ `__ \\ / // __ \\ / __ `// / \n' +
' (__ )/ /_/ // /_/ // /_/ / | |/ |/ // __// /_/ / / /_ / __// / / / / / / // // / / // /_/ // / \n' +
'/____// .___/ \\__,_/ \\__, / |__/|__/ \\___//_.___/ \\__/ \\___//_/ /_/ /_/ /_//_//_/ /_/ \\__,_//_/ \n' +
' /_/ /____/ \n'
_fetch = () => {
http.get(`/api/host/?id=${this.id}`)
.then(res => {
document.title = res.name;
this.setState({host: res, managerDisabled: false})
})
};
render() {
const {host, visible, managerDisabled} = this.state;
return (
<div className={styles.container}>
<div className={styles.header}>
<div>{host.name} | {host.username}@{host.hostname}:{host.port}</div>
<AuthDiv auth="host.console.manager">
<Button disabled={managerDisabled} type="primary" icon={<FolderOpenOutlined />}
onClick={this.handleShow}>文件管理器</Button>
</AuthDiv>
return (
<div className={styles.container}>
<div className={styles.sider}>
<div className={styles.logo}>
<img src={LogoSpugText} alt="logo"/>
</div>
<div className={styles.terminal}>
<div ref={ref => this.container = ref}/>
<div className={styles.hosts}>
<Spin spinning={fetching}>
<Tree.DirectoryTree
defaultExpandAll
expandAction="doubleClick"
treeData={treeData}
icon={renderIcon}
onSelect={(k, e) => handleSelect(e)}/>
</Spin>
</div>
<FileManager id={this.id} visible={visible} onClose={this.handleShow}/>
</div>
)
}
<div className={styles.content}>
<Tabs
hideAdd
activeKey={activeId}
type="editable-card"
onTabClick={key => setActiveId(key)}
onEdit={handleRemove}
tabBarExtraContent={<Button
type="primary"
style={{marginRight: 5}}
onClick={() => setVisible(true)}
icon={<FolderOpenOutlined/>}>文件管理器</Button>}>
{hosts.map(item => (
<Tabs.TabPane key={item.id} tab={item.title}>
<Terminal id={item.id} activeId={activeId}/>
</Tabs.TabPane>
))}
</Tabs>
<pre className={styles.fig}>{spug_web_terminal}</pre>
</div>
<FileManager id={activeId} visible={visible} onClose={() => setVisible(false)}/>
</div>
)
}
export default WebSSH
export default observer(WebSSH)

View File

@ -1,41 +0,0 @@
.container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
height: 46px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
background-color: #e6f7ff;
}
.actions {
display: flex;
align-items: center;
}
.terminal {
flex: 1;
display: flex;
background-color: #000;
padding-left: 5px;
}
.terminal > div {
flex: 1
}
.fileSize {
padding-right: 24px !important;
}
.drawerHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}

View File

@ -0,0 +1,76 @@
.container {
display: flex;
min-height: 100vh;
.sider {
display: flex;
flex-direction: column;
width: 220px;
background-color: #fafafa;
.logo {
height: 42px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f0f0f0;
img {
height: 30px;
}
}
.hosts {
margin-top: 12px;
:global(.ant-tree-node-content-wrapper) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.ant-tree) {
background-color: #fafafa;
}
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
:global(.ant-tabs-nav) {
width: calc(100vw - 220px);
height: 42px;
margin: 0;
}
:global(.ant-tabs-tab-active) {
border-bottom: 2px solid #1890ff !important;
transition: unset;
}
}
}
.terminal {
flex: 1;
display: flex;
background-color: #000;
padding-left: 5px;
height: calc(100vh - 42px);
}
.fig {
flex: 1;
background-color: #000;
color: #fff;
padding-top: 200px;
text-align: center;
}
.fileSize {
padding-right: 24px !important;
}
.drawerHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}