mirror of
https://github.com/nezhahq/nezha.git
synced 2025-01-22 12:48:14 -05:00
feat: framed fm for webshell (#411)
* feat: framed fm for webshell * 1MB buffer
This commit is contained in:
parent
9c986d06cb
commit
47f8447a22
@ -46,6 +46,8 @@ func (cp *commonPage) serve() {
|
||||
cr.GET("/network", cp.network)
|
||||
cr.GET("/ws", cp.ws)
|
||||
cr.POST("/terminal", cp.createTerminal)
|
||||
cr.GET("/file", cp.createFM)
|
||||
cr.GET("/file/:id", cp.fm)
|
||||
}
|
||||
|
||||
type viewPasswordForm struct {
|
||||
@ -257,8 +259,8 @@ func (cp *commonPage) home(c *gin.Context) {
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 10240,
|
||||
WriteBufferSize: 10240,
|
||||
ReadBufferSize: 32768,
|
||||
WriteBufferSize: 32768,
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
@ -427,5 +429,130 @@ func (cp *commonPage) createTerminal(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/terminal", mygin.CommonEnvironment(c, gin.H{
|
||||
"SessionID": streamId,
|
||||
"ServerName": server.Name,
|
||||
"ServerID": server.ID,
|
||||
}))
|
||||
}
|
||||
|
||||
func (cp *commonPage) fm(c *gin.Context) {
|
||||
streamId := c.Param("id")
|
||||
if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
|
||||
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||
Code: http.StatusForbidden,
|
||||
Title: "无权访问",
|
||||
Msg: "FM会话不存在",
|
||||
Link: "/",
|
||||
Btn: "返回首页",
|
||||
}, true)
|
||||
return
|
||||
}
|
||||
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
|
||||
|
||||
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||
Code: http.StatusInternalServerError,
|
||||
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
|
||||
MessageID: "NetworkError",
|
||||
}),
|
||||
Msg: "Websocket协议切换失败",
|
||||
Link: "/",
|
||||
Btn: "返回首页",
|
||||
}, true)
|
||||
return
|
||||
}
|
||||
defer wsConn.Close()
|
||||
conn := websocketx.NewConn(wsConn)
|
||||
|
||||
go func() {
|
||||
// PING 保活
|
||||
for {
|
||||
if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
|
||||
if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10)
|
||||
}
|
||||
|
||||
func (cp *commonPage) createFM(c *gin.Context) {
|
||||
IdString := c.Query("id")
|
||||
if _, authorized := c.Get(model.CtxKeyAuthorizedUser); !authorized {
|
||||
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||
Code: http.StatusForbidden,
|
||||
Title: "无权访问",
|
||||
Msg: "用户未登录",
|
||||
Link: "/login",
|
||||
Btn: "去登录",
|
||||
}, true)
|
||||
return
|
||||
}
|
||||
|
||||
streamId, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||
Code: http.StatusInternalServerError,
|
||||
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
|
||||
MessageID: "SystemError",
|
||||
}),
|
||||
Msg: "生成会话ID失败",
|
||||
Link: "/server",
|
||||
Btn: "返回重试",
|
||||
}, true)
|
||||
return
|
||||
}
|
||||
|
||||
rpc.NezhaHandlerSingleton.CreateStream(streamId)
|
||||
|
||||
serverId, err := strconv.Atoi(IdString)
|
||||
if err != nil {
|
||||
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||
Code: http.StatusForbidden,
|
||||
Title: "请求失败",
|
||||
Msg: "请求参数有误:" + err.Error(),
|
||||
Link: "/server",
|
||||
Btn: "返回重试",
|
||||
}, true)
|
||||
return
|
||||
}
|
||||
|
||||
singleton.ServerLock.RLock()
|
||||
server := singleton.ServerList[uint64(serverId)]
|
||||
singleton.ServerLock.RUnlock()
|
||||
if server == nil {
|
||||
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||
Code: http.StatusForbidden,
|
||||
Title: "请求失败",
|
||||
Msg: "服务器不存在或处于离线状态",
|
||||
Link: "/server",
|
||||
Btn: "返回重试",
|
||||
}, true)
|
||||
return
|
||||
}
|
||||
|
||||
fmData, _ := utils.Json.Marshal(&model.TaskFM{
|
||||
StreamID: streamId,
|
||||
})
|
||||
if err := server.TaskStream.Send(&proto.Task{
|
||||
Type: model.TaskTypeFM,
|
||||
Data: string(fmData),
|
||||
}); err != nil {
|
||||
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||
Code: http.StatusForbidden,
|
||||
Title: "请求失败",
|
||||
Msg: "Agent信令下发失败",
|
||||
Link: "/server",
|
||||
Btn: "返回重试",
|
||||
}, true)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/file", mygin.CommonEnvironment(c, gin.H{
|
||||
"SessionID": streamId,
|
||||
}))
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ const (
|
||||
TaskTypeTerminalGRPC
|
||||
TaskTypeNAT
|
||||
TaskTypeReportHostInfo
|
||||
TaskTypeFM
|
||||
)
|
||||
|
||||
type TerminalTask struct {
|
||||
@ -34,6 +35,10 @@ type TaskNAT struct {
|
||||
Host string
|
||||
}
|
||||
|
||||
type TaskFM struct {
|
||||
StreamID string
|
||||
}
|
||||
|
||||
const (
|
||||
MonitorCoverAll = iota
|
||||
MonitorCoverIgnoreAll
|
||||
|
508
resource/template/dashboard-default/file.html
vendored
Normal file
508
resource/template/dashboard-default/file.html
vendored
Normal file
@ -0,0 +1,508 @@
|
||||
{{define "dashboard-default/file"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Conf.Language}}">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>File List</title>
|
||||
<link rel="shortcut icon" type="image/png" href="/static/logo.svg?v20210804" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
<script src="https://unpkg.com/mdui@2/mdui.global.js"></script>
|
||||
</head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
margin: 5px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#top-app-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#current-directory {
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
margin: 0 15px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: calc(100% - 100px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div id="top-app-bar">
|
||||
<mdui-dropdown>
|
||||
<mdui-button-icon slot="trigger" icon="menu"></mdui-button-icon>
|
||||
<mdui-menu>
|
||||
<mdui-menu-item id="refresh">Refresh</mdui-menu-item>
|
||||
<mdui-menu-item id="copy">Copy path</mdui-menu-item>
|
||||
<mdui-menu-item id="goto">Go to</mdui-menu-item>
|
||||
</mdui-menu>
|
||||
</mdui-dropdown>
|
||||
<span id="current-directory"></span>
|
||||
<mdui-button-icon id="upload" icon="upload"></mdui-button-icon>
|
||||
</div>
|
||||
|
||||
<mdui-list id="file-list" class="file-list"></mdui-list>
|
||||
|
||||
<mdui-dialog id="error-dialog" headline="Error"
|
||||
description="Agent returned an error, please view the console for details. To open a new connection, reopen the FM again."></mdui-dialog>
|
||||
|
||||
<mdui-dialog id="upd-modal" class="modal">
|
||||
<mdui-linear-progress id="upd-progress"></mdui-linear-progress>
|
||||
</mdui-dialog>
|
||||
|
||||
<mdui-dialog id="goto-dialog" headline="Go to a folder" close-on-overlay-click>
|
||||
<mdui-text-field id="goto-text" variant="outlined" value=""></mdui-text-field>
|
||||
<mdui-button id="goto-go" slot="action" variant="text">Go</mdui-button>
|
||||
<mdui-button id="goto-close" slot="action" variant="tonal">Close</mdui-button>
|
||||
</mdui-dialog>
|
||||
|
||||
<script>
|
||||
let currentPath = '/opt/nezha/';
|
||||
let fileName = '';
|
||||
let receivedBuffer = []; // 用于缓存数据块
|
||||
let expectedLength = 0;
|
||||
let receivedLength = 0;
|
||||
let isFirstChunk = true;
|
||||
let isUpCompleted = false;
|
||||
|
||||
function updateDirectoryTitle() {
|
||||
const directoryTitle = document.getElementById('current-directory');
|
||||
directoryTitle.textContent = `${currentPath}`;
|
||||
}
|
||||
|
||||
function updateFileList(items) {
|
||||
const fileListElement = document.getElementById('file-list');
|
||||
fileListElement.innerHTML = '';
|
||||
|
||||
if (currentPath !== '/') {
|
||||
const upItem = document.createElement('mdui-list-item');
|
||||
upItem.className = 'file-item up-directory';
|
||||
upItem.setAttribute('icon', 'arrow_back');
|
||||
upItem.textContent = "..";
|
||||
upItem.onclick = function () {
|
||||
const lastSlashIndex = currentPath.lastIndexOf('/', currentPath.length - 2);
|
||||
currentPath = currentPath.substring(0, lastSlashIndex + 1) || '/';
|
||||
listFile();
|
||||
};
|
||||
fileListElement.appendChild(upItem);
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
if (a.fileType === 'dir' && b.fileType !== 'dir') {
|
||||
return -1;
|
||||
}
|
||||
if (a.fileType !== 'dir' && b.fileType === 'dir') {
|
||||
return 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
items.forEach(item => {
|
||||
const listItem = document.createElement('mdui-list-item');
|
||||
listItem.className = `file-item ${item.fileType.toLowerCase()}`;
|
||||
listItem.setAttribute('nonclickable', 'true');
|
||||
listItem.setAttribute('icon', 'insert_drive_file');
|
||||
listItem.textContent = `${item.name}`;
|
||||
|
||||
if (item.fileType === 'dir') {
|
||||
listItem.setAttribute('nonclickable', 'false');
|
||||
listItem.setAttribute('icon', 'folder');
|
||||
listItem.style.cursor = 'pointer';
|
||||
listItem.onclick = function () {
|
||||
currentPath += `${item.name}/`;
|
||||
listFile();
|
||||
};
|
||||
} else {
|
||||
const downloadButton = document.createElement('mdui-button-icon');
|
||||
downloadButton.setAttribute('slot', 'end-icon');
|
||||
downloadButton.setAttribute('icon', 'download');
|
||||
downloadButton.onclick = function () {
|
||||
const filePath = currentPath + item.name;
|
||||
fileName = item.name;
|
||||
downloadFile(filePath);
|
||||
};
|
||||
listItem.appendChild(downloadButton);
|
||||
}
|
||||
|
||||
fileListElement.appendChild(listItem);
|
||||
});
|
||||
updateDirectoryTitle();
|
||||
}
|
||||
|
||||
function resetUpdState() {
|
||||
receivedBuffer = [];
|
||||
expectedLength = 0;
|
||||
receivedLength = 0;
|
||||
isFirstChunk = true;
|
||||
updateProgress(0);
|
||||
}
|
||||
|
||||
function downloadFile(filePath) {
|
||||
showUpdModal('d');
|
||||
|
||||
const prefix = new Int8Array([1]); // Request download
|
||||
const filePathMessage = new TextEncoder().encode(filePath);
|
||||
|
||||
const msg = new Int8Array(prefix.length + filePathMessage.length);
|
||||
msg.set(prefix);
|
||||
msg.set(filePathMessage, prefix.length);
|
||||
|
||||
socket.send(msg);
|
||||
}
|
||||
|
||||
function listFile() {
|
||||
const prefix = new Int8Array([0]);
|
||||
const resizeMessage = new TextEncoder().encode(currentPath);
|
||||
|
||||
const msg = new Int8Array(prefix.length + resizeMessage.length);
|
||||
msg.set(prefix);
|
||||
msg.set(resizeMessage, prefix.length);
|
||||
|
||||
socket.send(msg);
|
||||
}
|
||||
|
||||
async function uploadFile(file) {
|
||||
showUpdModal('u');
|
||||
|
||||
const chunkSize = 1048576; // 1MB chunk
|
||||
let offset = 0;
|
||||
|
||||
const filePath = `${currentPath}${file.name}`;
|
||||
const fileSize = file.size;
|
||||
const messageType = 2;
|
||||
|
||||
// Build header (type + file size + path)
|
||||
const filePathBytes = new TextEncoder().encode(filePath);
|
||||
const header = new ArrayBuffer(1 + 8 + filePathBytes.length);
|
||||
const headerView = new DataView(header);
|
||||
|
||||
headerView.setUint8(0, messageType);
|
||||
headerView.setBigUint64(1, BigInt(fileSize), false);
|
||||
|
||||
new Uint8Array(header, 9).set(filePathBytes);
|
||||
|
||||
// Send header
|
||||
socket.send(header);
|
||||
|
||||
// Send data chunks
|
||||
while (offset < fileSize) {
|
||||
const chunk = file.slice(offset, offset + chunkSize);
|
||||
const arrayBuffer = await readFileAsArrayBuffer(chunk);
|
||||
socket.send(arrayBuffer);
|
||||
offset += chunkSize;
|
||||
}
|
||||
|
||||
const checkCompletion = setInterval(() => {
|
||||
if (isUpCompleted) {
|
||||
clearInterval(checkCompletion); // 任务完成后停止检查
|
||||
hideUpdModal();
|
||||
resetUpdState();
|
||||
listFile();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function parseFileList(arrayBuffer) {
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
const items = [];
|
||||
let offset = 4;
|
||||
|
||||
const pathLength = dataView.getUint32(offset);
|
||||
offset += 4;
|
||||
|
||||
const pathArray = new Uint8Array(arrayBuffer, offset, pathLength);
|
||||
currentPath = new TextDecoder('utf-8').decode(pathArray);
|
||||
offset += pathLength;
|
||||
|
||||
while (offset < dataView.byteLength) {
|
||||
const fileType = dataView.getUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
const nameLength = dataView.getUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
if (offset + nameLength > dataView.byteLength) {
|
||||
console.error('Error: Name length exceeds buffer size');
|
||||
break;
|
||||
}
|
||||
|
||||
const nameArray = new Uint8Array(arrayBuffer, offset, nameLength);
|
||||
const name = new TextDecoder('utf-8').decode(nameArray);
|
||||
offset += nameLength;
|
||||
|
||||
items.push({
|
||||
fileType: fileType === 0x01 ? 'dir' : 'f',
|
||||
name: name,
|
||||
});
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
async function handleDownloadFile(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
hideUpdModal();
|
||||
resetUpdState();
|
||||
}
|
||||
|
||||
function readFileAsArrayBuffer(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function concatenateArrayBuffers(buffers) {
|
||||
let totalLength = 0;
|
||||
buffers.forEach(buf => totalLength += buf.byteLength);
|
||||
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
buffers.forEach(buf => {
|
||||
result.set(new Uint8Array(buf), offset);
|
||||
offset += buf.byteLength;
|
||||
});
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
function arraysEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text to clipboard: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleError(errMsg) {
|
||||
try {
|
||||
console.error('Received error: ', errMsg);
|
||||
hideUpdModal();
|
||||
const errorDialog = document.getElementById('error-dialog');
|
||||
errorDialog.open = true;
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1000, 'Closing due to error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error while handling error and closing WebSocket:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdModal(operation) {
|
||||
const modal = document.getElementById('upd-modal');
|
||||
modal.open = true;
|
||||
if (operation === 'd') {
|
||||
modal.setAttribute('headline', 'Downloading...');
|
||||
modal.setAttribute('value', '0');
|
||||
modal.setAttribute('max', '100');
|
||||
} else if (operation === 'u') {
|
||||
modal.setAttribute('headline', 'Uploading...');
|
||||
}
|
||||
}
|
||||
|
||||
function hideUpdModal() {
|
||||
const modal = document.getElementById('upd-modal');
|
||||
modal.open = false;
|
||||
}
|
||||
|
||||
function updateProgress(percentage) {
|
||||
const progressBar = document.getElementById('upd-progress');
|
||||
progressBar.value = percentage;
|
||||
}
|
||||
|
||||
const socket = new WebSocket((window.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/file/' + '{{.SessionID}}');
|
||||
socket.binaryType = 'arraybuffer';
|
||||
|
||||
socket.onmessage = async function (event) {
|
||||
try {
|
||||
const arrayBuffer = event.data;
|
||||
|
||||
if (isFirstChunk) {
|
||||
const identifier = new Uint8Array(arrayBuffer, 0, 4);
|
||||
const fileIdentifier = new Uint8Array([0x4E, 0x5A, 0x54, 0x44]); // NZTD
|
||||
const fileNameIdentifier = new Uint8Array([0x4E, 0x5A, 0x46, 0x4E]); // NZFN
|
||||
const errorIdentifier = new Uint8Array([0x4E, 0x45, 0x52, 0x52]); // NERR
|
||||
const completeIdentifier = new Uint8Array([0x4E, 0x5A, 0x55, 0x50]); // NZUP
|
||||
|
||||
if (arraysEqual(identifier, fileIdentifier)) {
|
||||
// Download
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
expectedLength = Number(dataView.getBigUint64(4, false));
|
||||
|
||||
isFirstChunk = false;
|
||||
receivedLength = 0;
|
||||
|
||||
// Initialize writer
|
||||
const stream = new WritableStream({
|
||||
write(chunk) {
|
||||
receivedBuffer.push(chunk);
|
||||
},
|
||||
close() {
|
||||
// Save to blob
|
||||
const completeBlob = new Blob(receivedBuffer);
|
||||
handleDownloadFile(completeBlob);
|
||||
}
|
||||
});
|
||||
|
||||
writer = stream.getWriter();
|
||||
|
||||
// Read data after 12 bytes (if any)
|
||||
const dataChunk = arrayBuffer.slice(12);
|
||||
writer.write(dataChunk);
|
||||
receivedLength += dataChunk.byteLength;
|
||||
} else if (arraysEqual(identifier, fileNameIdentifier)) {
|
||||
// List files
|
||||
const { items } = await parseFileList(arrayBuffer);
|
||||
updateFileList(items);
|
||||
return;
|
||||
} else if (arraysEqual(identifier, errorIdentifier)) {
|
||||
// Handle error
|
||||
const errBytes = arrayBuffer.slice(4);
|
||||
const errMsg = new TextDecoder('utf-8').decode(errBytes);
|
||||
await handleError(errMsg);
|
||||
return;
|
||||
} else if (arraysEqual(identifier, completeIdentifier)) {
|
||||
// Upload is completed
|
||||
isUpCompleted = true;
|
||||
return;
|
||||
} else {
|
||||
console.log('Unknown identifier');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Handle data chunks
|
||||
receivedLength += arrayBuffer.byteLength;
|
||||
writer.write(arrayBuffer);
|
||||
|
||||
// Update progress bar
|
||||
const percentage = Math.min((receivedLength / expectedLength) * 100, 100);
|
||||
updateProgress(percentage);
|
||||
|
||||
if (receivedLength === expectedLength) {
|
||||
writer.close(); // Close the writer
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing received data:', error);
|
||||
if (writer) {
|
||||
writer.abort();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.onopen = function (event) {
|
||||
listFile();
|
||||
};
|
||||
|
||||
socket.onerror = function (event) {
|
||||
console.error('WebSocket error:', event);
|
||||
};
|
||||
|
||||
socket.onclose = function (event) {
|
||||
console.log('WebSocket connection closed:', event);
|
||||
};
|
||||
|
||||
document.getElementById('refresh').addEventListener('click', listFile);
|
||||
|
||||
document.getElementById('copy').addEventListener('click', async () => {
|
||||
await copyTextToClipboard(currentPath);
|
||||
});
|
||||
|
||||
document.getElementById('goto').addEventListener('click', function () {
|
||||
const dialog = document.getElementById('goto-dialog');
|
||||
const textField = document.getElementById('goto-text');
|
||||
const goButton = document.getElementById('goto-go');
|
||||
const closeButton = document.getElementById('goto-close');
|
||||
|
||||
dialog.open = true;
|
||||
|
||||
// Ensure the path ends with a separator
|
||||
const updateText = function (event) {
|
||||
let text = event.target.value;
|
||||
if (!text.endsWith('/')) {
|
||||
text += '/';
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGoClick = function () {
|
||||
let text = updateText({ target: textField });
|
||||
currentPath = text;
|
||||
listFile();
|
||||
dialog.open = false;
|
||||
};
|
||||
|
||||
textField.removeEventListener('change', updateText);
|
||||
textField.addEventListener('change', updateText);
|
||||
|
||||
goButton.removeEventListener('click', handleGoClick);
|
||||
goButton.addEventListener('click', handleGoClick);
|
||||
|
||||
closeButton.addEventListener("click", () => dialog.open = false);
|
||||
});
|
||||
|
||||
document.getElementById('upload').addEventListener('click', async function () {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.style.display = 'none';
|
||||
|
||||
fileInput.addEventListener('change', async function (event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
document.body.removeChild(fileInput);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
@ -23,10 +23,43 @@
|
||||
body {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#file-list-iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background-color: white;
|
||||
display: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#folder-button {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body onresize="onResize()">
|
||||
<div id="terminal-container"></div>
|
||||
<iframe id="file-list-iframe" src=""></iframe>
|
||||
<button id="folder-button">📁</button>
|
||||
|
||||
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
||||
<script src="https://unpkg.com/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js"></script>
|
||||
@ -95,6 +128,27 @@
|
||||
socket.onerror = () => {
|
||||
alert('{{tr "TerminalConnectionFailed"}}')
|
||||
}
|
||||
|
||||
// 处理文件夹按钮点击事件
|
||||
const folderButton = document.getElementById('folder-button');
|
||||
const fileListIframe = document.getElementById('file-list-iframe');
|
||||
let fileListVisible = false;
|
||||
|
||||
folderButton.addEventListener('click', () => {
|
||||
if (!fileListVisible) {
|
||||
// 显示文件列表
|
||||
const params = new URLSearchParams({
|
||||
id: "{{.ServerID}}"
|
||||
}).toString();
|
||||
fileListIframe.src = `/file?${params}`;
|
||||
fileListIframe.style.display = 'block';
|
||||
fileListVisible = true;
|
||||
} else {
|
||||
// 隐藏文件列表
|
||||
fileListIframe.style.display = 'none';
|
||||
fileListVisible = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
@ -117,7 +117,7 @@ LOOP:
|
||||
endCh := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
_, innerErr := io.Copy(stream.userIo, stream.agentIo)
|
||||
_, innerErr := io.CopyBuffer(stream.userIo, stream.agentIo, make([]byte, 1048576))
|
||||
if innerErr != nil {
|
||||
err = innerErr
|
||||
}
|
||||
@ -126,7 +126,7 @@ LOOP:
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
_, innerErr := io.Copy(stream.agentIo, stream.userIo)
|
||||
_, innerErr := io.CopyBuffer(stream.agentIo, stream.userIo, make([]byte, 1048576))
|
||||
if innerErr != nil {
|
||||
err = innerErr
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user