diff --git a/spug_api/apps/host/group.py b/spug_api/apps/host/group.py index 5523640..8509454 100644 --- a/spug_api/apps/host/group.py +++ b/spug_api/apps/host/group.py @@ -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() diff --git a/spug_api/apps/host/models.py b/spug_api/apps/host/models.py index 3d48fa9..fa2f275 100644 --- a/spug_api/apps/host/models.py +++ b/spug_api/apps/host/models.py @@ -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' diff --git a/spug_web/src/pages/ssh/FileManager.js b/spug_web/src/pages/ssh/FileManager.js index 765a96d..ed1bb5b 100644 --- a/spug_web/src/pages/ssh/FileManager.js +++ b/spug_web/src/pages/ssh/FileManager.js @@ -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() diff --git a/spug_web/src/pages/ssh/Terminal.js b/spug_web/src/pages/ssh/Terminal.js new file mode 100644 index 0000000..a1ccdb2 --- /dev/null +++ b/spug_web/src/pages/ssh/Terminal.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * 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 ( +
+ ) +} + +export default WebSSH \ No newline at end of file diff --git a/spug_web/src/pages/ssh/index.js b/spug_web/src/pages/ssh/index.js index 85188d1..14c94b0 100644 --- a/spug_web/src/pages/ssh/index.js +++ b/spug_web/src/pages/ssh/index.js @@ -3,98 +3,120 @@ * Copyright (c) * 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 + } else if (node.expanded) { + return + } else { + return + } + } - 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 ( -
-
-
{host.name} | {host.username}@{host.hostname}:{host.port}
- - - + return ( +
+
+
+ logo
-
-
this.container = ref}/> +
+ + handleSelect(e)}/> +
-
- ) - } +
+ setActiveId(key)} + onEdit={handleRemove} + tabBarExtraContent={}> + {hosts.map(item => ( + + + + ))} + +
{spug_web_terminal}
+
+ setVisible(false)}/> +
+ ) } -export default WebSSH \ No newline at end of file +export default observer(WebSSH) \ No newline at end of file diff --git a/spug_web/src/pages/ssh/index.module.css b/spug_web/src/pages/ssh/index.module.css deleted file mode 100644 index 6081274..0000000 --- a/spug_web/src/pages/ssh/index.module.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/spug_web/src/pages/ssh/index.module.less b/spug_web/src/pages/ssh/index.module.less new file mode 100644 index 0000000..8f0ca67 --- /dev/null +++ b/spug_web/src/pages/ssh/index.module.less @@ -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; +} \ No newline at end of file