mirror of https://github.com/openspug/spug
upgrade web terminal
parent
90537943d6
commit
1883dba226
|
@ -7,14 +7,14 @@ from libs import json_response, JsonParser, Argument
|
||||||
from apps.host.models import Group
|
from apps.host.models import Group
|
||||||
|
|
||||||
|
|
||||||
def fetch_children(data):
|
def fetch_children(data, with_hosts):
|
||||||
if data:
|
if data:
|
||||||
sub_data = dict()
|
sub_data = dict()
|
||||||
for item in Group.objects.filter(parent_id__in=data.keys()):
|
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
|
sub_data[item.id] = tmp
|
||||||
data[item.parent_id]['children'].append(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):
|
def merge_children(data, prefix, childes):
|
||||||
|
@ -22,7 +22,7 @@ def merge_children(data, prefix, childes):
|
||||||
for item in childes:
|
for item in childes:
|
||||||
name = f'{prefix}{item["title"]}'
|
name = f'{prefix}{item["title"]}'
|
||||||
item['name'] = name
|
item['name'] = name
|
||||||
if item['children']:
|
if item.get('children'):
|
||||||
merge_children(data, name, item['children'])
|
merge_children(data, name, item['children'])
|
||||||
else:
|
else:
|
||||||
data[item['key']] = name
|
data[item['key']] = name
|
||||||
|
@ -30,10 +30,11 @@ def merge_children(data, prefix, childes):
|
||||||
|
|
||||||
class GroupView(View):
|
class GroupView(View):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
with_hosts = request.GET.get('with_hosts')
|
||||||
data, data2 = dict(), dict()
|
data, data2 = dict(), dict()
|
||||||
for item in Group.objects.filter(parent_id=0):
|
for item in Group.objects.filter(parent_id=0):
|
||||||
data[item.id] = item.to_view()
|
data[item.id] = item.to_view(with_hosts)
|
||||||
fetch_children(data)
|
fetch_children(data, with_hosts)
|
||||||
if not data:
|
if not data:
|
||||||
grp = Group.objects.create(name='Default', sort_id=1)
|
grp = Group.objects.create(name='Default', sort_id=1)
|
||||||
data[grp.id] = grp.to_view()
|
data[grp.id] = grp.to_view()
|
||||||
|
|
|
@ -48,13 +48,14 @@ class Group(models.Model, ModelMixin):
|
||||||
sort_id = models.IntegerField(default=0)
|
sort_id = models.IntegerField(default=0)
|
||||||
hosts = models.ManyToManyField(Host, related_name='groups')
|
hosts = models.ManyToManyField(Host, related_name='groups')
|
||||||
|
|
||||||
def to_view(self):
|
def to_view(self, with_hosts=False):
|
||||||
return {
|
response = dict(key=self.id, value=self.id, title=self.name, children=[])
|
||||||
'key': self.id,
|
if with_hosts:
|
||||||
'value': self.id,
|
def make_item(x):
|
||||||
'title': self.name,
|
return dict(title=x.name, key=f'{self.id}_{x.id}', id=x.id, isLeaf=True)
|
||||||
'children': []
|
|
||||||
}
|
response['children'] = [make_item(x) for x in self.hosts.all()]
|
||||||
|
return response
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'host_groups'
|
db_table = 'host_groups'
|
||||||
|
|
|
@ -15,14 +15,13 @@ import {
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { http, uniqueId, X_TOKEN } from 'libs';
|
import { http, uniqueId, X_TOKEN } from 'libs';
|
||||||
import lds from 'lodash';
|
import lds from 'lodash';
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.less'
|
||||||
|
|
||||||
|
|
||||||
class FileManager extends React.Component {
|
class FileManager extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.input = null;
|
this.input = null;
|
||||||
this.id = props.id;
|
|
||||||
this.state = {
|
this.state = {
|
||||||
fetching: false,
|
fetching: false,
|
||||||
showDot: false,
|
showDot: false,
|
||||||
|
@ -92,7 +91,7 @@ class FileManager extends React.Component {
|
||||||
this.setState({fetching: true});
|
this.setState({fetching: true});
|
||||||
pwd = pwd || this.state.pwd;
|
pwd = pwd || this.state.pwd;
|
||||||
const path = '/' + pwd.join('/');
|
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 => {
|
.then(res => {
|
||||||
const objects = lds.orderBy(res, [this._kindSort, 'name'], ['desc', 'asc']);
|
const objects = lds.orderBy(res, [this._kindSort, 'name'], ['desc', 'asc']);
|
||||||
this.setState({objects, pwd})
|
this.setState({objects, pwd})
|
||||||
|
@ -154,7 +153,7 @@ class FileManager extends React.Component {
|
||||||
handleDownload = (name) => {
|
handleDownload = (name) => {
|
||||||
const file = `/${this.state.pwd.join('/')}/${name}`;
|
const file = `/${this.state.pwd.join('/')}/${name}`;
|
||||||
const link = document.createElement('a');
|
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);
|
document.body.appendChild(link);
|
||||||
const evt = document.createEvent("MouseEvents");
|
const evt = document.createEvent("MouseEvents");
|
||||||
evt.initEvent("click", false, false);
|
evt.initEvent("click", false, false);
|
||||||
|
@ -169,7 +168,7 @@ class FileManager extends React.Component {
|
||||||
title: '删除文件确认',
|
title: '删除文件确认',
|
||||||
content: `确认删除文件:${file} ?`,
|
content: `确认删除文件:${file} ?`,
|
||||||
onOk: () => {
|
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(() => {
|
.then(() => {
|
||||||
message.success('删除成功');
|
message.success('删除成功');
|
||||||
this.fetchFiles()
|
this.fetchFiles()
|
||||||
|
|
|
@ -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
|
|
@ -3,98 +3,120 @@
|
||||||
* Copyright (c) <spug.dev@gmail.com>
|
* Copyright (c) <spug.dev@gmail.com>
|
||||||
* Released under the AGPL-3.0 License.
|
* Released under the AGPL-3.0 License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { FolderOpenOutlined } from '@ant-design/icons';
|
import { observer } from 'mobx-react';
|
||||||
import { Button } from 'antd';
|
import { Tabs, Tree, Button, Spin } from 'antd';
|
||||||
import { AuthDiv } from 'components';
|
import { FolderOutlined, FolderOpenOutlined, CloudServerOutlined } from '@ant-design/icons';
|
||||||
import { Terminal } from 'xterm';
|
import Terminal from './Terminal';
|
||||||
import { FitAddon } from 'xterm-addon-fit';
|
|
||||||
import FileManager from './FileManager';
|
import FileManager from './FileManager';
|
||||||
import { http, X_TOKEN } from 'libs';
|
import { http } from 'libs';
|
||||||
import 'xterm/css/xterm.css';
|
import styles from './index.module.less';
|
||||||
import styles from './index.module.css';
|
import LogoSpugText from 'layout/logo-spug-txt.png';
|
||||||
|
import lds from 'lodash';
|
||||||
|
|
||||||
|
|
||||||
class WebSSH extends React.Component {
|
function WebSSH(props) {
|
||||||
constructor(props) {
|
const [visible, setVisible] = useState(false);
|
||||||
super(props);
|
const [fetching, setFetching] = useState(true);
|
||||||
this.id = props.match.params.id;
|
const [treeData, setTreeData] = useState([]);
|
||||||
this.socket = null;
|
const [hosts, setHosts] = useState([]);
|
||||||
this.term = new Terminal();
|
const [activeId, setActiveId] = useState();
|
||||||
this.container = null;
|
|
||||||
this.input = null;
|
useEffect(() => {
|
||||||
this.state = {
|
window.addEventListener('beforeunload', leaveTips)
|
||||||
visible: false,
|
http.get('/api/host/group/?with_hosts=1')
|
||||||
uploading: false,
|
.then(res => setTreeData(res.treeData))
|
||||||
managerDisabled: true,
|
.finally(() => setFetching(false))
|
||||||
host: {},
|
return () => window.removeEventListener('beforeunload', leaveTips)
|
||||||
percent: 0
|
}, [])
|
||||||
|
|
||||||
|
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() {
|
function handleRemove(key, action) {
|
||||||
this._fetch();
|
if (action === 'remove') {
|
||||||
const fitPlugin = new FitAddon();
|
const index = lds.findIndex(hosts, x => String(x.id) === key);
|
||||||
this.term.loadAddon(fitPlugin);
|
if (index !== -1) {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
hosts.splice(index, 1);
|
||||||
this.socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/ssh/${this.id}/?x-token=${X_TOKEN}`);
|
setHosts(lds.cloneDeep(hosts));
|
||||||
this.socket.onmessage = e => this._read_as_text(e.data);
|
if (hosts.length > index) {
|
||||||
this.socket.onopen = () => {
|
setActiveId(String(hosts[index].id))
|
||||||
this.term.open(this.container);
|
} else if (hosts.length) {
|
||||||
this.term.focus();
|
setActiveId(String(hosts[index - 1].id))
|
||||||
fitPlugin.fit();
|
}
|
||||||
};
|
}
|
||||||
this.socket.onclose = e => {
|
}
|
||||||
if (e.code === 3333) {
|
}
|
||||||
window.location.href = "about:blank";
|
|
||||||
window.close()
|
function renderIcon(node) {
|
||||||
|
if (node.isLeaf) {
|
||||||
|
return <CloudServerOutlined/>
|
||||||
|
} else if (node.expanded) {
|
||||||
|
return <FolderOpenOutlined/>
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => this.term.write('\r\nConnection is closed.\r\n'), 200)
|
return <FolderOutlined/>
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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 spug_web_terminal =
|
||||||
const reader = new window.FileReader();
|
' __ __ _ __\n' +
|
||||||
reader.onload = () => this.term.write(reader.result);
|
' _____ ____ __ __ ____ _ _ __ ___ / /_ / /_ ___ _____ ____ ___ (_)____ ____ _ / /\n' +
|
||||||
reader.readAsText(data, 'utf-8')
|
' / ___// __ \\ / / / // __ `/ | | /| / // _ \\ / __ \\ / __// _ \\ / ___// __ `__ \\ / // __ \\ / __ `// / \n' +
|
||||||
};
|
' (__ )/ /_/ // /_/ // /_/ / | |/ |/ // __// /_/ / / /_ / __// / / / / / / // // / / // /_/ // / \n' +
|
||||||
|
'/____// .___/ \\__,_/ \\__, / |__/|__/ \\___//_.___/ \\__/ \\___//_/ /_/ /_/ /_//_//_/ /_/ \\__,_//_/ \n' +
|
||||||
|
' /_/ /____/ \n'
|
||||||
|
|
||||||
handleShow = () => {
|
|
||||||
this.setState({visible: !this.state.visible})
|
|
||||||
};
|
|
||||||
|
|
||||||
_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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.sider}>
|
||||||
<div>{host.name} | {host.username}@{host.hostname}:{host.port}</div>
|
<div className={styles.logo}>
|
||||||
<AuthDiv auth="host.console.manager">
|
<img src={LogoSpugText} alt="logo"/>
|
||||||
<Button disabled={managerDisabled} type="primary" icon={<FolderOpenOutlined />}
|
|
||||||
onClick={this.handleShow}>文件管理器</Button>
|
|
||||||
</AuthDiv>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.terminal}>
|
<div className={styles.hosts}>
|
||||||
<div ref={ref => this.container = ref}/>
|
<Spin spinning={fetching}>
|
||||||
|
<Tree.DirectoryTree
|
||||||
|
defaultExpandAll
|
||||||
|
expandAction="doubleClick"
|
||||||
|
treeData={treeData}
|
||||||
|
icon={renderIcon}
|
||||||
|
onSelect={(k, e) => handleSelect(e)}/>
|
||||||
|
</Spin>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export default WebSSH
|
export default observer(WebSSH)
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
}
|
Loading…
Reference in New Issue