diff --git a/.umirc.ts b/.umirc.ts
index 94e49782..9145d658 100644
--- a/.umirc.ts
+++ b/.umirc.ts
@@ -11,7 +11,8 @@ export default defineConfig({
favicon: 'https://qinglong.whyour.cn/g5.ico',
proxy: {
'/api': {
- target: 'http://127.0.0.1:5678/',
+ target: 'http://127.0.0.1:5600',
+ ws: true,
changeOrigin: true,
},
},
diff --git a/back/api/index.ts b/back/api/index.ts
index 38ba05e3..55bce445 100644
--- a/back/api/index.ts
+++ b/back/api/index.ts
@@ -4,6 +4,7 @@ import cookie from './cookie';
import config from './config';
import log from './log';
import cron from './cron';
+import terminal from './terminal';
export default () => {
const app = Router();
@@ -12,6 +13,7 @@ export default () => {
config(app);
log(app);
cron(app);
+ terminal(app);
return app;
};
diff --git a/back/api/terminal.ts b/back/api/terminal.ts
new file mode 100644
index 00000000..e0438d65
--- /dev/null
+++ b/back/api/terminal.ts
@@ -0,0 +1,113 @@
+import { Router } from 'express';
+import * as pty from 'node-pty';
+import os from 'os';
+// Whether to use binary transport.
+const USE_BINARY = os.platform() !== 'win32';
+const route = Router();
+
+export default (app: Router) => {
+ const terminals = {};
+ const logs = {};
+ app.use('/', route);
+ route.post('/terminals', (req, res) => {
+ const env = Object.assign({}, process.env);
+ env['COLORTERM'] = 'truecolor';
+ var cols = parseInt(req.query.cols),
+ rows = parseInt(req.query.rows),
+ term = pty.spawn(process.platform === 'win32' ? 'cmd.exe' : 'bash', [], {
+ name: 'xterm-256color',
+ cols: cols || 80,
+ rows: rows || 24,
+ cwd: process.platform === 'win32' ? undefined : env.PWD,
+ env: env,
+ encoding: USE_BINARY ? null : 'utf8',
+ });
+
+ console.log('Created terminal with PID: ' + term.pid);
+ terminals[term.pid] = term;
+ logs[term.pid] = '';
+ term.on('data', function (data) {
+ logs[term.pid] += data;
+ });
+ res.send(term.pid.toString());
+ res.end();
+ });
+
+ route.post('/terminals/:pid/size', (req, res) => {
+ var pid = parseInt(req.params.pid),
+ cols = parseInt(req.query.cols),
+ rows = parseInt(req.query.rows),
+ term = terminals[pid];
+
+ term.resize(cols, rows);
+ console.log(
+ 'Resized terminal ' +
+ pid +
+ ' to ' +
+ cols +
+ ' cols and ' +
+ rows +
+ ' rows.',
+ );
+ res.end();
+ });
+
+ route.ws('/terminals/:pid', function (ws, req) {
+ var term = terminals[parseInt(req.params.pid)];
+ console.log('Connected to terminal ' + term.pid);
+ ws.send(logs[term.pid]);
+
+ // string message buffering
+ function buffer(socket, timeout) {
+ let s = '';
+ let sender = null;
+ return (data) => {
+ s += data;
+ if (!sender) {
+ sender = setTimeout(() => {
+ socket.send(s);
+ s = '';
+ sender = null;
+ }, timeout);
+ }
+ };
+ }
+ // binary message buffering
+ function bufferUtf8(socket, timeout) {
+ let buffer = [];
+ let sender = null;
+ let length = 0;
+ return (data) => {
+ buffer.push(data);
+ length += data.length;
+ if (!sender) {
+ sender = setTimeout(() => {
+ socket.send(Buffer.concat(buffer, length));
+ buffer = [];
+ sender = null;
+ length = 0;
+ }, timeout);
+ }
+ };
+ }
+ const send = USE_BINARY ? bufferUtf8(ws, 5) : buffer(ws, 5);
+
+ term.on('data', function (data) {
+ try {
+ send(data);
+ } catch (ex) {
+ // The WebSocket is not open, ignore
+ }
+ });
+ ws.on('message', function (msg) {
+ term.write(msg);
+ });
+ ws.on('close', function () {
+ term.kill();
+ console.log('Closed terminal ' + term.pid);
+ // Clean things up
+ delete terminals[term.pid];
+ delete logs[term.pid];
+ });
+ });
+};
diff --git a/back/loaders/express.ts b/back/loaders/express.ts
index 35b16192..d9635d92 100644
--- a/back/loaders/express.ts
+++ b/back/loaders/express.ts
@@ -5,34 +5,87 @@ import routes from '../api';
import config from '../config';
import jwt from 'express-jwt';
import fs from 'fs';
+import http from 'http';
+import expressWs from 'express-ws';
+import Logger from './logger';
+
+const excludePath = ['/api/login'];
+
+const auth = (getToken?: jwt.Options['getToken']) =>
+ jwt({
+ secret: config.secret as string,
+ algorithms: ['HS384'],
+ getToken,
+ });
+
+const getTokenFromReq = (req) => {
+ if (
+ req.headers.authorization &&
+ req.headers.authorization.split(' ')[0] === 'Bearer'
+ ) {
+ return req.headers.authorization.split(' ')[1];
+ } else if (req.query && req.query.token) {
+ return req.query.token;
+ }
+ return null;
+};
export default ({ app }: { app: Application }) => {
+ const server = http.createServer(app);
+
+ app.listen = function serverListen(...args) {
+ return server.listen(...args);
+ };
+
+ const wsInstance = expressWs(app, server);
+
+ // server.on('upgrade', function upgrade(request, socket, head) {
+ // const wss = wsInstance.getWss();
+ // auth((req) => {
+ // const searchParams = new URLSearchParams(
+ // req.url.slice(req.url.indexOf('?')),
+ // );
+ // return searchParams.get('token');
+ // })(request, {} as any, (err) => {
+ // Logger.error(err);
+ // if (err) {
+ // socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ // socket.destroy();
+ // return;
+ // }
+
+ // wss.handleUpgrade(request, socket, head, function done(ws) {
+ // wss.emit('connection', ws, request);
+ // });
+ // });
+ // });
+
app.enable('trust proxy');
app.use(cors());
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
- app.use(
- jwt({ secret: config.secret as string, algorithms: ['HS384'] }).unless({
- path: ['/api/login'],
- }),
- );
- app.use((req, res, next) => {
- if (req.url && req.url.includes('/api/login')) {
- return next();
- }
- const data = fs.readFileSync(config.authConfigFile, 'utf8');
- const authHeader = req.headers.authorization;
- if (data) {
- const { token } = JSON.parse(data);
- if (token && authHeader.includes(token)) {
- return next();
- }
- }
- const err: any = new Error('UnauthorizedError');
- err['status'] = 401;
- next(err);
- });
+ // app.use(
+ // auth(getTokenFromReq).unless({
+ // path: excludePath,
+ // }),
+ // );
+ // app.use((req, res, next) => {
+ // if (req.url && excludePath.includes(req.path)) {
+ // return next();
+ // }
+ // const data = fs.readFileSync(config.authConfigFile, 'utf8');
+ // const authHeader = getTokenFromReq(req);
+ // if (data) {
+ // const { token } = JSON.parse(data);
+ // if (token && authHeader.includes(token)) {
+ // return next();
+ // }
+ // }
+ // const err: any = new Error('UnauthorizedError');
+ // err['status'] = 401;
+ // next(err);
+ // });
app.use(config.api.prefix, routes());
app.use((req, res, next) => {
diff --git a/back/test/websocket.js b/back/test/websocket.js
new file mode 100644
index 00000000..eaf93018
--- /dev/null
+++ b/back/test/websocket.js
@@ -0,0 +1,13 @@
+const WebSocket = require('ws');
+
+const ws = new WebSocket(
+ 'ws://localhost:5600/api/terminal/123?token=eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiJMV0QtRkR5OGVZS19pMG55cGVSYjYiLCJpYXQiOjE2MjI3MjYwMDksImV4cCI6MTYyMzMzMDgwOX0.skpXpLQ9Rzbwsj17NFSC3BVoLEqf9ttvLh3JR6irKcY40mLbw--pCDL5QlmEjOem',
+);
+
+ws.on('open', function open() {
+ ws.send('something');
+});
+
+ws.on('message', function incoming(data) {
+ console.log(data);
+});
diff --git a/package.json b/package.json
index f07b5b22..95967329 100644
--- a/package.json
+++ b/package.json
@@ -31,19 +31,25 @@
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-jwt": "^6.0.0",
+ "express-ws": "^4.0.0",
"got": "^11.8.2",
"jsonwebtoken": "^8.5.1",
"nedb": "^1.8.0",
"node-fetch": "^2.6.1",
+ "node-pty": "^0.10.1",
"node-schedule": "^2.0.0",
"p-queue": "6.6.2",
"reflect-metadata": "^0.1.13",
"typedi": "^0.8.0",
- "winston": "^3.3.3"
+ "winston": "^3.3.3",
+ "ws": "^7.4.6",
+ "xterm": "^4.12.0",
+ "xterm-addon-attach": "^0.6.0",
+ "xterm-addon-fit": "^0.5.0"
},
"devDependencies": {
- "@ant-design/pro-layout": "^6.5.0",
"@ant-design/icons": "^4.6.2",
+ "@ant-design/pro-layout": "^6.5.0",
"@types/cors": "^2.8.10",
"@types/express": "^4.17.8",
"@types/express-jwt": "^6.0.1",
@@ -54,6 +60,7 @@
"@types/qrcode.react": "^1.0.1",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
+ "@types/ws": "^7.4.4",
"@umijs/plugin-antd": "^0.9.1",
"@umijs/test": "^3.3.9",
"codemirror": "^5.59.4",
diff --git a/src/layouts/defaultProps.tsx b/src/layouts/defaultProps.tsx
index b852905b..f699ab6d 100644
--- a/src/layouts/defaultProps.tsx
+++ b/src/layouts/defaultProps.tsx
@@ -30,6 +30,12 @@ export default {
icon: ,
component: '@/pages/cookie/index',
},
+ {
+ path: '/terminal',
+ name: '终端管理',
+ icon: ,
+ component: '@/pages/terminal/index',
+ },
{
path: '/config',
name: '配置文件',
diff --git a/src/pages/terminal/index.module.less b/src/pages/terminal/index.module.less
new file mode 100644
index 00000000..fb59c15c
--- /dev/null
+++ b/src/pages/terminal/index.module.less
@@ -0,0 +1,43 @@
+@import '~@/styles/variable.less';
+
+.left-tree {
+ &-container {
+ overflow: hidden;
+ position: relative;
+ // padding: 16px 0;
+ background-color: #fff;
+ height: calc(100vh - 128px);
+ height: calc(100vh - var(--vh-offset, 0px) - 128px);
+ width: @tree-width;
+ display: flex;
+ flex-direction: column;
+ }
+ &-scroller {
+ flex: 1;
+ overflow: auto;
+ }
+ &-search {
+ margin-bottom: 16px;
+ // position: absolute;
+ // top: 0;
+ // left: 0;
+ // padding-bottom: 16px;
+ }
+}
+
+.log-container {
+ display: flex;
+}
+
+:global {
+ .log-wrapper {
+ .ant-pro-grid-content.wide .ant-pro-page-container-children-content {
+ padding: 0;
+ background-color: #f8f8f8;
+ }
+
+ .CodeMirror {
+ width: calc(100% - 32px - @tree-width);
+ }
+ }
+}
diff --git a/src/pages/terminal/index.tsx b/src/pages/terminal/index.tsx
new file mode 100644
index 00000000..3f229364
--- /dev/null
+++ b/src/pages/terminal/index.tsx
@@ -0,0 +1,156 @@
+import { useState, useEffect, useCallback, Key, useRef } from 'react';
+import config from '@/utils/config';
+import { PageContainer } from '@ant-design/pro-layout';
+import { request } from '@/utils/http';
+import { Terminal } from 'xterm';
+import 'xterm/css/xterm.css';
+import { AttachAddon } from 'xterm-addon-attach';
+import { FitAddon } from 'xterm-addon-fit';
+import { getToken } from '@/utils/auth';
+
+function getFilterData(keyword: string, data: any) {
+ const expandedKeys: string[] = [];
+ if (keyword) {
+ const tree: any = [];
+ data.forEach((item) => {
+ if (item.title.includes(keyword)) {
+ tree.push(item);
+ expandedKeys.push(...item.children.map((x) => x.key));
+ } else {
+ const children: any[] = [];
+ (item.children || []).forEach((subItem: any) => {
+ if (subItem.title.includes(keyword)) {
+ children.push(subItem);
+ }
+ });
+ if (children.length > 0) {
+ tree.push({
+ ...item,
+ children,
+ });
+ expandedKeys.push(...children.map((x) => x.key));
+ }
+ }
+ });
+ return { tree, expandedKeys };
+ }
+ return { tree: data, expandedKeys };
+}
+
+const Log = () => {
+ const [width, setWdith] = useState('100%');
+ const [marginLeft, setMarginLeft] = useState(0);
+ const [marginTop, setMarginTop] = useState(-72);
+ const [title, setTitle] = useState('请选择日志文件');
+ const [value, setValue] = useState('请选择日志文件');
+ const [select, setSelect] = useState();
+ const [data, setData] = useState([]);
+ const [filterData, setFilterData] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [isPhone, setIsPhone] = useState(false);
+
+ const getConfig = () => {
+ request.get(`${config.apiPrefix}logs`).then((data) => {
+ const result = formatData(data.dirs) as any;
+ setData(result);
+ setFilterData(result);
+ });
+ };
+
+ const formatData = (tree: any[]) => {
+ return tree.map((x) => {
+ x.title = x.name;
+ x.value = x.name;
+ x.disabled = x.isDir;
+ x.key = x.name;
+ x.children = x.files.map((y: string) => ({
+ title: y,
+ value: `${x.name}/${y}`,
+ key: `${x.name}/${y}`,
+ parent: x.name,
+ isLeaf: true,
+ }));
+ return x;
+ });
+ };
+
+ const getLog = (node: any) => {
+ setLoading(true);
+ request
+ .get(`${config.apiPrefix}logs/${node.value}`)
+ .then((data) => {
+ setValue(data.data);
+ })
+ .finally(() => setLoading(false));
+ };
+
+ const onSelect = (value: any, node: any) => {
+ setSelect(value);
+ setTitle(node.parent || node.value);
+ getLog(node);
+ };
+
+ const ref = useRef(null);
+
+ useEffect(() => {
+ setLoading(true);
+ request
+ .post(`${config.apiPrefix}terminals?cols=80&rows=24`)
+ .then((pid) => {
+ const ws = new WebSocket(
+ `ws://${location.host}${
+ config.apiPrefix
+ }terminals/${pid}?token=${getToken()}`,
+ );
+ ws.onopen = () => {
+ const term = new Terminal();
+ const attachAddon = new AttachAddon(ws);
+ const fitAddon = new FitAddon();
+ term.loadAddon(attachAddon);
+ term.loadAddon(fitAddon);
+ term.open(ref.current);
+ fitAddon.fit();
+ term.focus();
+ };
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => {
+ if (document.body.clientWidth < 768) {
+ setWdith('auto');
+ setMarginLeft(0);
+ setMarginTop(0);
+ setIsPhone(true);
+ } else {
+ setWdith('100%');
+ setMarginLeft(0);
+ setMarginTop(-72);
+ setIsPhone(false);
+ }
+ getConfig();
+ }, []);
+
+ return (
+
+
+
+ );
+};
+
+export default Log;
diff --git a/src/utils/auth.ts b/src/utils/auth.ts
new file mode 100644
index 00000000..57fb5519
--- /dev/null
+++ b/src/utils/auth.ts
@@ -0,0 +1,6 @@
+import config from './config';
+
+export function getToken() {
+ const token = localStorage.getItem(config.authKey);
+ return token;
+}
diff --git a/src/utils/http.ts b/src/utils/http.ts
index 7c473e6e..5befff04 100644
--- a/src/utils/http.ts
+++ b/src/utils/http.ts
@@ -9,11 +9,11 @@ message.config({
const time = Date.now();
const errorHandler = function (error: any) {
if (error.response) {
- const message = error.data
+ const msg = error.data
? error.data.message || error.data
: error.response.statusText;
if (error.response.status !== 401 && error.response.status !== 502) {
- message.error(message);
+ message.error(msg);
} else {
console.log(error.response);
}