mirror of https://github.com/openspug/spug
A 增加主机Console文件管理器功能
parent
ad08f2c355
commit
25739fcb07
|
@ -0,0 +1,3 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the MIT License.
|
|
@ -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()),
|
||||
]
|
|
@ -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
|
||||
}
|
|
@ -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)
|
|
@ -7,5 +7,4 @@ from .views import *
|
|||
|
||||
urlpatterns = [
|
||||
path('', HostView.as_view()),
|
||||
path('ssh/<int:h_id>/', web_ssh),
|
||||
]
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -93,7 +93,7 @@ CHANNEL_LAYERS = {
|
|||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [os.path.join(BASE_DIR, 'templates')],
|
||||
'DIRS': [],
|
||||
'APP_DIRS': False,
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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')),
|
||||
]
|
||||
|
|
|
@ -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>
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue