A 增加主机Console文件管理器功能

pull/103/head
vapao 2020-05-27 12:54:36 +08:00
parent ad08f2c355
commit 25739fcb07
23 changed files with 590 additions and 191 deletions

View File

@ -0,0 +1,3 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the MIT License.

View File

@ -0,0 +1,11 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the MIT License.
from django.urls import path
from .views import *
urlpatterns = [
path('', FileView.as_view()),
path('object/', ObjectView.as_view()),
]

View File

@ -0,0 +1,87 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the MIT License.
from django.http import FileResponse
import stat
import time
KB = 1024
MB = 1024 * 1024
GB = 1024 * 1024 * 1024
TB = 1024 * 1024 * 1024 * 1024
class FileResponseAfter(FileResponse):
def __init__(self, callback, *args, **kwargs):
super().__init__(*args, **kwargs)
self.callback = callback
def close(self):
super().close()
self.callback()
def parse_mode(obj):
if obj.st_mode:
mt = stat.S_IFMT(obj.st_mode)
if mt == stat.S_IFIFO:
kind = "p"
elif mt == stat.S_IFCHR:
kind = "c"
elif mt == stat.S_IFDIR:
kind = "d"
elif mt == stat.S_IFBLK:
kind = "b"
elif mt == stat.S_IFREG:
kind = "-"
elif mt == stat.S_IFLNK:
kind = "l"
elif mt == stat.S_IFSOCK:
kind = "s"
else:
kind = "?"
code = obj._rwx(
(obj.st_mode & 448) >> 6, obj.st_mode & stat.S_ISUID
)
code += obj._rwx(
(obj.st_mode & 56) >> 3, obj.st_mode & stat.S_ISGID
)
code += obj._rwx(
obj.st_mode & 7, obj.st_mode & stat.S_ISVTX, True
)
else:
kind = "?"
code = '---------'
return kind, code
def format_size(size):
if size:
if size < KB:
return f'{size}B'
if size < MB:
return f'{size / KB:.1f}K'
if size < GB:
return f'{size / MB:.1f}M'
if size < TB:
return f'{size / GB:.1f}G'
return f'{size / TB:.1f}T'
else:
return ''
def parse_sftp_attr(obj):
if (obj.st_mtime is None) or (obj.st_mtime == int(0xffffffff)):
date = "(unknown date)"
else:
date = time.strftime('%Y/%m/%d %H:%M:%S', time.localtime(obj.st_mtime))
kind, code = parse_mode(obj)
is_dir = stat.S_ISDIR(obj.st_mode) if obj.st_mode else False
size = obj.st_size or ''
return {
'name': getattr(obj, 'filename', '?'),
'size': '' if is_dir else format_size(size),
'date': date,
'kind': kind,
'code': code
}

View File

@ -0,0 +1,81 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the MIT License.
from django.views.generic import View
from django_redis import get_redis_connection
from apps.host.models import Host
from apps.file.utils import FileResponseAfter, parse_sftp_attr
from libs import json_response, JsonParser, Argument
from functools import partial
import os
class FileView(View):
def get(self, request):
form, error = JsonParser(
Argument('id', type=int, help='参数错误'),
Argument('path', help='参数错误')
).parse(request.GET)
if error is None:
host = Host.objects.get(pk=form.id)
if not host:
return json_response(error='未找到指定主机')
cli = host.get_ssh()
objects = cli.list_dir_attr(form.path)
return json_response([parse_sftp_attr(x) for x in objects])
return json_response(error=error)
class ObjectView(View):
def get(self, request):
form, error = JsonParser(
Argument('id', type=int, help='参数错误'),
Argument('file', help='请输入文件路径')
).parse(request.GET)
if error is None:
host = Host.objects.filter(pk=1).first()
if not host:
return json_response(error='未找到指定主机')
filename = os.path.basename(form.file)
cli = host.get_ssh().get_client()
sftp = cli.open_sftp()
f = sftp.open(form.file)
return FileResponseAfter(cli.close, f, as_attachment=True, filename=filename)
return json_response(error=error)
def post(self, request):
form, error = JsonParser(
Argument('id', type=int, help='参数错误'),
Argument('token', help='参数错误'),
Argument('path', help='参数错误'),
).parse(request.POST)
if error is None:
file = request.FILES.get('file')
if not file:
return json_response(error='请选择要上传的文件')
host = Host.objects.get(pk=form.id)
if not host:
return json_response(error='未找到指定主机')
cli = host.get_ssh()
rds_cli = get_redis_connection()
callback = partial(self._compute_progress, rds_cli, form.token, file.size)
cli.put_file_by_fl(file, os.path.join(form.path, file.name), callback=callback)
return json_response(error=error)
def delete(self, request):
form, error = JsonParser(
Argument('id', type=int, help='参数错误'),
Argument('file', help='请输入文件路径')
).parse(request.GET)
if error is None:
host = Host.objects.get(pk=form.id)
if not host:
return json_response(error='未找到指定主机')
cli = host.get_ssh()
cli.remove_file(form.file)
return json_response(error=error)
def _compute_progress(self, rds_cli, token, total, value, *args):
percent = '%.1f' % (value / total * 100)
rds_cli.lpush(token, percent)
rds_cli.expire(token, 300)

View File

@ -7,5 +7,4 @@ from .views import *
urlpatterns = [
path('', HostView.as_view()),
path('ssh/<int:h_id>/', web_ssh),
]

View File

@ -2,8 +2,6 @@
# Copyright: (c) <spug.dev@gmail.com>
# Released under the MIT License.
from django.views.generic import View
from django.shortcuts import render
from django.http.response import HttpResponseBadRequest
from django.db.models import F
from libs import json_response, JsonParser, Argument
from apps.setting.utils import AppSetting
@ -17,6 +15,9 @@ from libs import human_datetime
class HostView(View):
def get(self, request):
host_id = request.GET.get('id')
if host_id:
return json_response(Host.objects.get(pk=host_id))
hosts = Host.objects.filter(deleted_by_id__isnull=True)
zones = [x['zone'] for x in hosts.order_by('zone').values('zone').distinct()]
return json_response({'zones': zones, 'hosts': [x.to_dict() for x in hosts]})
@ -68,14 +69,6 @@ class HostView(View):
return json_response(error=error)
def web_ssh(request, h_id):
host = Host.objects.filter(pk=h_id).first()
if not host:
return HttpResponseBadRequest('unknown host')
context = {'id': h_id, 'title': host.name, 'token': request.user.access_token}
return render(request, 'web_ssh.html', context)
def valid_ssh(hostname, port, username, password):
try:
private_key = AppSetting.get('private_key')

View File

@ -85,6 +85,21 @@ class SSH:
out = stdout.readline()
yield chan.recv_exit_status(), out
def put_file_by_fl(self, fl, remote_path, callback=None):
with self as cli:
sftp = cli.open_sftp()
sftp.putfo(fl, remote_path, callback=callback)
def list_dir_attr(self, path):
with self as cli:
sftp = cli.open_sftp()
return sftp.listdir_attr(path)
def remove_file(self, path):
with self as cli:
sftp = cli.open_sftp()
sftp.remove(path)
def __enter__(self):
if self.client is not None:
raise RuntimeError('Already connected')

View File

@ -93,7 +93,7 @@ CHANNEL_LAYERS = {
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'DIRS': [],
'APP_DIRS': False,
},
]

View File

@ -31,5 +31,6 @@ urlpatterns = [
path('deploy/', include('apps.deploy.urls')),
path('home/', include('apps.home.urls')),
path('notify/', include('apps.notify.urls')),
path('file/', include('apps.file.urls')),
path('apis/', include('apps.apis.urls')),
]

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
<link href="/xterm/xterm.min.css" rel="stylesheet" type="text/css"/>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<div id="terminal"></div>
<script src="/xterm/xterm.min.js"></script>
<script src="/xterm/xterm-addon-fit.min.js"></script>
<script src="/xterm/main.js"></script>
<script>
run('{{ id }}', '{{ token }}')
</script>
</body>
</html>

View File

@ -18,7 +18,9 @@
"react-ace": "^8.0.0",
"react-dom": "^16.11.0",
"react-router-dom": "^5.1.2",
"react-scripts": "3.2.0"
"react-scripts": "3.2.0",
"xterm": "^4.6.0",
"xterm-addon-fit": "^0.4.0"
},
"scripts": {
"start": "react-app-rewired start",

View File

@ -1,149 +0,0 @@
let style = {};
function get_cell_size(term) {
style.width = term._core._renderService._renderer.dimensions.actualCellWidth;
style.height = term._core._renderService._renderer.dimensions.actualCellHeight;
}
function current_geometry(term) {
if (!style.width || !style.height) {
get_cell_size(term);
}
const cols = parseInt(window.innerWidth / style.width, 10) - 1;
const rows = parseInt(window.innerHeight / style.height, 10);
return {'cols': cols, 'rows': rows};
}
function resize_terminal(term) {
const geometry = current_geometry(term);
term.on_resize(geometry.cols, geometry.rows);
}
function read_as_text_with_decoder(file, callback, decoder) {
let reader = new window.FileReader();
if (decoder === undefined) {
decoder = new window.TextDecoder('utf-8', {'fatal': true});
}
reader.onload = function () {
let text;
try {
text = decoder.decode(reader.result);
} catch (TypeError) {
console.log('Decoding error happened.');
} finally {
if (callback) {
callback(text);
}
}
};
reader.onerror = function (e) {
console.error(e);
};
reader.readAsArrayBuffer(file);
}
function read_as_text_with_encoding(file, callback, encoding) {
let reader = new window.FileReader();
if (encoding === undefined) {
encoding = 'utf-8';
}
reader.onload = function () {
if (callback) {
callback(reader.result);
}
};
reader.onerror = function (e) {
console.error(e);
};
reader.readAsText(file, encoding);
}
function read_file_as_text(file, callback, decoder) {
if (!window.TextDecoder) {
read_as_text_with_encoding(file, callback, decoder);
} else {
read_as_text_with_decoder(file, callback, decoder);
}
}
function run(id, token) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const sock = new window.WebSocket(`${protocol}//${window.location.host}/api/ws/ssh/${token}/${id}/`),
encoding = 'utf-8',
decoder = window.TextDecoder ? new window.TextDecoder(encoding) : encoding,
terminal = document.getElementById('terminal'),
term = new window.Terminal({
cursorBlink: true,
theme: {
background: 'black'
}
});
term.fitAddon = new window.FitAddon.FitAddon();
term.loadAddon(term.fitAddon);
function term_write(text) {
if (term) {
term.write(text);
if (!term.resized) {
resize_terminal(term);
term.resized = true;
}
}
}
term.on_resize = function (cols, rows) {
if (cols !== this.cols || rows !== this.rows) {
this.resize(cols, rows);
sock.send(JSON.stringify({'resize': [cols, rows]}));
}
};
term.onData(function (data) {
sock.send(JSON.stringify({'data': data}));
});
sock.onopen = function () {
term.open(terminal);
term.fitAddon.fit();
term.focus();
};
sock.onmessage = function (msg) {
read_file_as_text(msg.data, term_write, decoder);
};
sock.onerror = function (e) {
console.error(e);
};
sock.onclose = function (e) {
if (e.code === 3333) {
window.location.href = "about:blank";
window.close()
} else {
setTimeout(() => term_write('\r\nConnection is closed.\r\n'), 200)
}
};
window.onresize = function () {
if (term) {
resize_terminal(term);
}
};
}

View File

@ -1,2 +0,0 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(window,function(){return function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}return r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=0)}([function(e,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n=function(){function e(){}return e.prototype.activate=function(e){this._terminal=e},e.prototype.dispose=function(){},e.prototype.fit=function(){var e=this.proposeDimensions();if(e&&this._terminal){var t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}},e.prototype.proposeDimensions=function(){if(this._terminal&&this._terminal.element&&this._terminal.element.parentElement){var e=this._terminal._core,t=window.getComputedStyle(this._terminal.element.parentElement),r=parseInt(t.getPropertyValue("height")),n=Math.max(0,parseInt(t.getPropertyValue("width"))),o=window.getComputedStyle(this._terminal.element),i=r-(parseInt(o.getPropertyValue("padding-top"))+parseInt(o.getPropertyValue("padding-bottom"))),a=n-(parseInt(o.getPropertyValue("padding-right"))+parseInt(o.getPropertyValue("padding-left")))-e.viewport.scrollBarWidth;return{cols:Math.max(2,Math.floor(a/e._renderService.dimensions.actualCellWidth)),rows:Math.max(1,Math.floor(i/e._renderService.dimensions.actualCellHeight))}}},e}();t.FitAddon=n}])});
//# sourceMappingURL=xterm-addon-fit.js.map

View File

@ -1 +0,0 @@
.xterm{font-feature-settings:"liga" 0;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#FFF;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm{cursor:text}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility,.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:0.5}.xterm-underline{text-decoration:underline}

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@
import React, { Component } from 'react';
import {Switch, Route} from 'react-router-dom';
import Login from './pages/login';
import WebSSH from './pages/ssh';
import Layout from './layout';
class App extends Component {
@ -13,6 +14,7 @@ class App extends Component {
return (
<Switch>
<Route path="/" exact component={Login} />
<Route path="/ssh/:id" exact component={WebSSH} />
<Route component={Layout} />
</Switch>
);

View File

@ -62,3 +62,11 @@ export function human_time(date) {
const second = now.getSeconds() < 10 ? '0' + now.getSeconds() : now.getSeconds();
return `${human_date()} ${hour}:${minute}:${second}`
}
// 生成唯一id
export function uniqueId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
});
}

View File

@ -39,7 +39,7 @@ http.interceptors.request.use(request => {
if (request.url.startsWith('/api/')) {
request.headers['X-Token'] = localStorage.getItem('token')
}
request.timeout = 30000;
request.timeout = request.timeout || 30000;
return request;
});

View File

@ -53,7 +53,7 @@ class ComTable extends React.Component {
}];
handleConsole = (info) => {
window.open(`/api/host/ssh/${info.id}/?x-token=${localStorage.getItem('token')}`)
window.open(`/ssh/${info.id}`)
};
handleDelete = (text) => {

View File

@ -0,0 +1,227 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the MIT License.
*/
import React from 'react';
import { Drawer, Breadcrumb, Table, Icon, Divider, Switch, Button, Progress, Modal, message } from 'antd';
import { http, uniqueId } from 'libs';
import lds from 'lodash';
import styles from './index.module.css'
class FileManager extends React.Component {
constructor(props) {
super(props);
this.input = null;
this.id = props.id;
this.state = {
fetching: false,
showDot: false,
uploading: false,
uploadStatus: 'active',
pwd: [],
objects: [],
percent: 0
}
}
columns = [{
title: '名称',
key: 'name',
render: info => info.kind === 'd' ? (
<div onClick={() => this.handleChdir(info.name, '1')} style={{cursor: 'pointer'}}>
<Icon type="folder" style={{color: '#1890ff'}}/>
<span style={{color: '#1890ff', paddingLeft: 5}}>{info.name}</span>
</div>
) : (
<React.Fragment>
<Icon type="file"/>
<span style={{paddingLeft: 5}}>{info.name}</span>
</React.Fragment>
),
ellipsis: true
}, {
title: '大小',
dataIndex: 'size',
align: 'right',
className: styles.fileSize,
width: 90
}, {
title: '修改时间',
dataIndex: 'date',
width: 190
}, {
title: '属性',
key: 'attr',
render: info => `${info.kind}${info.code}`,
width: 120
}, {
title: '操作',
width: 80,
align: 'right',
key: 'action',
render: info => info.kind === '-' ? (
<React.Fragment>
<Icon style={{color: '#1890ff'}} type="download" onClick={() => this.handleDownload(info.name)}/>
<Divider type="vertical"/>
<Icon style={{color: 'red'}} type="delete" onClick={() => this.handleDelete(info.name)}/>
</React.Fragment>
) : null
}];
onShow = (visible) => {
if (visible) {
this.fetchFiles()
}
};
_kindSort = (item) => {
return item.kind === 'd'
};
fetchFiles = () => {
this.setState({fetching: true});
const path = '/' + this.state.pwd.join('/');
http.get('/api/file/', {params: {id: 1, path}})
.then(res => {
const objects = lds.orderBy(res, [this._kindSort, 'name'], ['desc', 'asc']);
this.setState({objects})
})
.finally(() => this.setState({fetching: false}))
};
handleChdir = (name, action) => {
let pwd = this.state.pwd;
if (action === '1') {
pwd.push(name)
} else if (action === '2') {
const index = pwd.indexOf(name);
pwd = pwd.splice(0, index + 1)
} else {
pwd = []
}
this.setState({pwd}, this.fetchFiles);
};
handleUpload = () => {
this.input.click();
this.input.onchange = e => {
this.setState({uploading: true, uploadStatus: 'active', percent: 0});
const file = e.target['files'][0];
const formData = new FormData();
const token = uniqueId();
this._updatePercent(token);
formData.append('file', file);
formData.append('id', this.id);
formData.append('token', token);
formData.append('path', '/' + this.state.pwd.join('/'));
this.input.value = '';
http.post('/api/file/object/', formData, {timeout: 600000})
.then(() => {
this.setState({uploadStatus: 'success'});
this.fetchFiles()
}, () => this.setState({uploadStatus: 'exception'}))
.finally(() => setTimeout(() => this.setState({uploading: false}), 2000))
}
};
_updatePercent = token => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/exec/${token}/`);
this.socket.onopen = () => this.socket.send('ok');
this.socket.onmessage = e => {
if (e.data === 'pong') {
this.socket.send('ping')
} else {
this.setState({percent: Number(e.data)});
if (Number(e.data) === 100) {
this.socket.close()
}
}
}
};
handleDownload = (name) => {
const file = `/${this.state.pwd.join('/')}/${name}`;
const token = localStorage.getItem('token');
const link = document.createElement('a');
link.href = `/api/file/object/?id=${this.id}&file=${file}&x-token=${token}`;
document.body.appendChild(link);
const evt = document.createEvent("MouseEvents");
evt.initEvent("click", false, false);
link.dispatchEvent(evt);
document.body.removeChild(link);
message.warning('即将开始下载,请勿重复点击。')
};
handleDelete = (name) => {
const file = `/${this.state.pwd.join('/')}/${name}`;
Modal.confirm({
title: '删除文件确认',
content: `确认删除文件:${file} ?`,
onOk: () => {
return http.delete('/api/file/object/', {params: {id: this.id, file}})
.then(() => {
message.success('删除成功');
this.fetchFiles()
})
}
})
};
render() {
let objects = this.state.objects;
if (!this.state.showDot) {
objects = objects.filter(x => !x.name.startsWith('.'))
}
const scrollY = document.body.clientHeight - 222;
return (
<Drawer
title="文件管理器"
placement="right"
width={900}
afterVisibleChange={this.onShow}
visible={this.props.visible}
onClose={this.props.onClose}>
<input style={{display: 'none'}} type="file" ref={ref => this.input = ref}/>
<div className={styles.drawerHeader}>
<Breadcrumb>
<Breadcrumb.Item href="#" onClick={() => this.handleChdir('', '0')}>
<Icon type="home"/>
</Breadcrumb.Item>
{this.state.pwd.map(item => (
<Breadcrumb.Item key={item} href="#" onClick={() => this.handleChdir(item, '2')}>
<span>{item}</span>
</Breadcrumb.Item>
))}
</Breadcrumb>
<div style={{display: 'flex', alignItems: 'center'}}>
<span>显示隐藏文件</span>
<Switch
checked={this.state.showDot}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={v => this.setState({showDot: v})}/>
<Button style={{marginLeft: 10}} size="small" type="primary" icon="upload"
onClick={this.handleUpload}>上传文件</Button>
</div>
</div>
{this.state.uploading && (
<Progress style={{marginBottom: 15}} status={this.state.uploadStatus} percent={this.state.percent}/>
)}
<Table
size="small"
rowKey="name"
loading={this.state.fetching}
pagination={false}
columns={this.columns}
scroll={{y: scrollY}}
bodyStyle={{fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace"}}
dataSource={objects}/>
</Drawer>
)
}
}
export default FileManager

View File

@ -0,0 +1,95 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the MIT License.
*/
import React from 'react';
import { Button } from 'antd';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import FileManager from './FileManager';
import { http } from 'libs';
import 'xterm/css/xterm.css';
import styles from './index.module.css';
class WebSSH extends React.Component {
constructor(props) {
super(props);
this.id = props.match.params.id;
this.token = localStorage.getItem('token');
this.socket = null;
this.term = new Terminal();
this.container = null;
this.input = null;
this.state = {
visible: false,
uploading: false,
host: {},
percent: 0
}
}
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.token}/${this.id}/`);
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)
}
};
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')
};
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})
})
};
render() {
const {host, visible} = this.state;
return (
<div className={styles.container}>
<div className={styles.header}>
<div>{host.name} | {host.username}@{host.hostname}:{host.port}</div>
<Button type="primary" icon="folder-open" onClick={this.handleShow}>文件管理器</Button>
</div>
<div className={styles.terminal}>
<div ref={ref => this.container = ref}/>
</div>
<FileManager id={this.id} visible={visible} onClose={this.handleShow} />
</div>
)
}
}
export default WebSSH

View File

@ -0,0 +1,41 @@
.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

@ -11925,6 +11925,16 @@ xtend@^4.0.0, xtend@~4.0.1:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
xterm-addon-fit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz#06e0c5d0a6aaacfb009ef565efa1c81e93d90193"
integrity sha512-p4BESuV/g2L6pZzFHpeNLLnep9mp/DkF3qrPglMiucSFtD8iJxtMufEoEJbN8LZwB4i+8PFpFvVuFrGOSpW05w==
xterm@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.6.0.tgz#1b49b32e546409c110fbe8ece0b4a388504a937d"
integrity sha512-98211RIDrAECqpsxs6gbilwMcxLtxSDIvtzZUIqP1xIByXtuccJ4pmMhHGJATZeEGe/reARPMqwPINK8T7jGZg==
"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"