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
|
||||
|
||||
|
||||
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()
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
* 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)
|
|
@ -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