✨ plus&功能重构
This commit is contained in:
parent
1fdf8c6a09
commit
2c41928f65
3
.gitignore
vendored
3
.gitignore
vendored
@ -11,3 +11,6 @@ server/app/db/*
|
|||||||
plan.md
|
plan.md
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env-encrypt-key
|
||||||
|
*clear.js
|
||||||
|
local-script
|
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,3 +1,14 @@
|
|||||||
|
## [3.0.0](https://github.com/chaos-zhu/easynode/releases) (2024-11-09)
|
||||||
|
|
||||||
|
* 新增跳板机功能,支持选择多台机器跳转
|
||||||
|
* 脚本库批量导出导入
|
||||||
|
* 本地socket断开自动重连,无需手动重新连接
|
||||||
|
* 支持脚本库模糊搜索功能
|
||||||
|
* 分组添加实例数量标识
|
||||||
|
* 优化登录逻辑
|
||||||
|
* 默认登录有效期更改为当天有效
|
||||||
|
* 修复一些小bug
|
||||||
|
|
||||||
## [2.3.0](https://github.com/chaos-zhu/easynode/releases) (2024-10-24)
|
## [2.3.0](https://github.com/chaos-zhu/easynode/releases) (2024-10-24)
|
||||||
|
|
||||||
* 重构本地数据库存储方式(性能提升一个level~)
|
* 重构本地数据库存储方式(性能提升一个level~)
|
||||||
|
102
README.md
102
README.md
@ -1,29 +1,43 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
# EasyNode
|
# EasyNode
|
||||||
|
|
||||||
<!-- - [功能](#功能)
|
_✨ 一个多功能Linux服务器WEB终端面板(webSSH&webSFTP) ✨_
|
||||||
- [安装](#安装)
|
|
||||||
- [监控服务安装](#监控服务安装)
|
</div>
|
||||||
- [版本日志](#版本日志)
|
|
||||||
- [开发](#开发)
|
<p align="center">
|
||||||
- [QA](#QA)
|
<a href="#功能">功能</a>
|
||||||
- [安全与建议](#安全与建议)
|
·
|
||||||
- [捐赠](#捐赠)
|
<a href="#动图展示">动图展示</a>
|
||||||
- [License](#license) -->
|
·
|
||||||
|
<a href="#项目部署">项目部署</a>
|
||||||
|
·
|
||||||
|
<a href="#监控服务安装">监控服务安装</a>
|
||||||
|
·
|
||||||
|
<a href="#安全与建议">安全与建议</a>
|
||||||
|
·
|
||||||
|
<a href="#常见问题">常见问题</a>
|
||||||
|
<!-- ·
|
||||||
|
<a href="#Plus功能">Plus版功能</a> -->
|
||||||
|
</p>
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- [x] 功能完善的**SSH终端**&**SFTP**
|
+ [x] 功能完善的**SSH终端**&**SFTP**
|
||||||
- [x] 批量**导入导出**实例(Xshell&FinalShell&EasyNode)
|
+ [x] 批量导入、导出、编辑服务器配置、脚本等
|
||||||
- [x] **实例分组**
|
+ [x] 脚本库
|
||||||
- [x] **凭据托管**
|
+ [x] 实例分组
|
||||||
- [x] **多渠道通知**
|
+ [x] 凭据托管
|
||||||
- [x] **脚本库**
|
+ [x] 多渠道通知
|
||||||
- [x] **批量指令**
|
+ [x] 批量下发指令
|
||||||
- [x] **终端主题背景自定义**
|
+ [x] 自定义终端主题
|
||||||
|
|
||||||
|
## 动图展示
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 安装
|
## 项目部署
|
||||||
|
|
||||||
- 默认账户密码 `admin/admin`
|
- 默认账户密码 `admin/admin`
|
||||||
- web端口:8082
|
- web端口:8082
|
||||||
@ -31,34 +45,17 @@
|
|||||||
### docker镜像
|
### docker镜像
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker run -d -p 8082:8082 --name=easynode --restart=always -v /root/easynode/db:/easynode/app/db chaoszhu/easynode
|
docker run -d -p 8082:8082 --restart=always -v /root/easynode/db:/easynode/app/db chaoszhu/easynode
|
||||||
```
|
```
|
||||||
环境变量:
|
环境变量:
|
||||||
|
- `PLUS_KEY`: 激活PLUS功能的授权码
|
||||||
- `DEBUG`: 启动debug日志 0:关闭 1:开启, 默认关闭
|
- `DEBUG`: 启动debug日志 0:关闭 1:开启, 默认关闭
|
||||||
- `ALLOWED_IPS`: 可以访问服务的IP白名单, 多个使用逗号分隔, 支持填写部分ip前缀, 例如: `-e ALLOWED_IPS=127.0.0.1,196.168`
|
- `ALLOWED_IPS`: 可以访问服务的IP白名单, 多个使用逗号分隔, 支持填写部分ip前缀, 例如: `-e ALLOWED_IPS=127.0.0.1,196.168`
|
||||||
|
|
||||||
### 手动部署
|
|
||||||
|
|
||||||
依赖Nodejs版本 > 20+
|
|
||||||
|
|
||||||
```shell
|
|
||||||
git clone https://github.com/chaos-zhu/easynode
|
|
||||||
cd easynode
|
|
||||||
yarn
|
|
||||||
cd web
|
|
||||||
yarn build
|
|
||||||
mv dist/* ../server/app/static
|
|
||||||
cd ../server
|
|
||||||
yarn start
|
|
||||||
# 后台运行需安装pm2
|
|
||||||
pm2 start index.js --name easynode-server
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 监控服务安装
|
## 监控服务安装
|
||||||
|
|
||||||
- 监控服务用于实时向服务端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息,不安装不影响使用面板,但是无法实时同步cpu占用、实时网速、硬盘容量等实用信息。
|
- 监控服务用于实时向服务端&web端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息
|
||||||
|
|
||||||
- 默认端口:**22022**
|
- 默认端口:**22022**
|
||||||
|
|
||||||
@ -86,20 +83,6 @@ curl -o- https://ghp.ci/https://raw.githubusercontent.com/chaos-zhu/easynode/mai
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 开发
|
|
||||||
|
|
||||||
1. 拉取代码,环境 `nodejs>=20`
|
|
||||||
2. cd到项目根目录,`yarn install` 执行安装依赖
|
|
||||||
3. `yarn dev`启动项目
|
|
||||||
4. web: `http://localhost:18090/`
|
|
||||||
|
|
||||||
## 版本日志
|
|
||||||
|
|
||||||
- [CHANGELOG](./CHANGELOG.md)
|
|
||||||
|
|
||||||
## QA
|
|
||||||
|
|
||||||
- [QA](./Q%26A.md)
|
|
||||||
|
|
||||||
## 安全与建议
|
## 安全与建议
|
||||||
|
|
||||||
@ -110,12 +93,17 @@ curl -o- https://ghp.ci/https://raw.githubusercontent.com/chaos-zhu/easynode/mai
|
|||||||
|
|
||||||
webssh与监控服务都将以`该服务器作为中转`。中国大陆用户建议使用香港、新加坡、日本、韩国等地区的低延迟服务器来安装服务端面板
|
webssh与监控服务都将以`该服务器作为中转`。中国大陆用户建议使用香港、新加坡、日本、韩国等地区的低延迟服务器来安装服务端面板
|
||||||
|
|
||||||
## 捐赠
|
## 常见问题
|
||||||
|
|
||||||
如果您认为此项目帮到了您, 您可以请我喝杯阔乐~
|
- [QA](./Q%26A.md)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## License
|
<!-- ## Plus版功能
|
||||||
|
|
||||||
[MIT](LICENSE). Copyright (c).
|
- 跳板机功能,拯救被墙实例与龟速终端输入
|
||||||
|
- 本地socket断开自动重连,无需手动重新连接
|
||||||
|
- 批量修改实例配置(优化版)
|
||||||
|
- 脚本库批量导出导入
|
||||||
|
- 凭据管理支持解密带密码保护的密钥
|
||||||
|
- 提出的功能需求享有更高的开发优先级 -->
|
||||||
|
@ -22,7 +22,8 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\"",
|
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\"",
|
||||||
"clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules"
|
"clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules",
|
||||||
|
"encrypt": "node ./local-script/encrypt-file.js"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/chaos-zhu/easynode/issues"
|
"url": "https://github.com/chaos-zhu/easynode/issues"
|
||||||
|
@ -3,3 +3,6 @@ DEBUG=1
|
|||||||
|
|
||||||
# 访问IP限制
|
# 访问IP限制
|
||||||
allowedIPs=['127.0.0.1']
|
allowedIPs=['127.0.0.1']
|
||||||
|
|
||||||
|
# 激活PLUS功能的授权码
|
||||||
|
PLUS_KEY=
|
||||||
|
@ -17,6 +17,7 @@ module.exports = {
|
|||||||
notifyConfigDBPath: path.join(process.cwd(),'app/db/notify-config.db'),
|
notifyConfigDBPath: path.join(process.cwd(),'app/db/notify-config.db'),
|
||||||
onekeyDBPath: path.join(process.cwd(),'app/db/onekey.db'),
|
onekeyDBPath: path.join(process.cwd(),'app/db/onekey.db'),
|
||||||
logDBPath: path.join(process.cwd(),'app/db/log.db'),
|
logDBPath: path.join(process.cwd(),'app/db/log.db'),
|
||||||
|
plusDBPath: path.join(process.cwd(),'app/db/plus.db'),
|
||||||
apiPrefix: '/api/v1',
|
apiPrefix: '/api/v1',
|
||||||
logConfig: {
|
logConfig: {
|
||||||
outDir: path.join(process.cwd(),'./app/logs'),
|
outDir: path.join(process.cwd(),'./app/logs'),
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
const { RSADecryptAsync, AESEncryptAsync, AESDecryptAsync } = require('../utils/encrypt')
|
const { RSADecryptAsync, AESEncryptAsync, AESDecryptAsync } = require('../utils/encrypt')
|
||||||
const { HostListDB } = require('../utils/db-class')
|
const { HostListDB } = require('../utils/db-class')
|
||||||
const hostListDB = new HostListDB().getInstance()
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
|
||||||
async function getHostList({ res }) {
|
async function getHostList({ res }) {
|
||||||
// console.log('get-host-list')
|
|
||||||
let data = await hostListDB.findAsync({})
|
let data = await hostListDB.findAsync({})
|
||||||
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
|
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
|
||||||
for (const item of data) {
|
for (const item of data) {
|
||||||
try {
|
try {
|
||||||
let { username, port, authType, _id: id, credential } = item
|
let { authType, _id: id, credential } = item
|
||||||
// console.log('解密凭证title: ', credential)
|
|
||||||
if (credential) credential = await AESDecryptAsync(credential)
|
if (credential) credential = await AESDecryptAsync(credential)
|
||||||
const isConfig = Boolean(username && port && (item[authType]))
|
const isConfig = Boolean(authType && item[authType])
|
||||||
Object.assign(item, { id, isConfig, password: '', privateKey: '', credential })
|
Object.assign(item, { id, isConfig, password: '', privateKey: '', credential })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
consola.error('getHostList error: ', error.message)
|
consola.error('getHostList error: ', error.message)
|
||||||
@ -21,94 +21,55 @@ async function getHostList({ res }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addHost({ res, request }) {
|
async function addHost({ res, request }) {
|
||||||
let {
|
let { body } = request
|
||||||
body: {
|
if (!body.name || !body.host) return res.fail({ msg: 'missing params: name or host' })
|
||||||
name, host, index, expired, expiredNotify, group, consoleUrl, remark,
|
let newRecord = { ...body }
|
||||||
port: newPort, clientPort, username, authType, password, privateKey, credential, command, tempKey
|
const { authType, tempKey } = newRecord
|
||||||
}
|
if (newRecord[authType] && tempKey) {
|
||||||
} = request
|
|
||||||
// console.log(request)
|
|
||||||
if (!host || !name) return res.fail({ msg: 'missing params: name or host' })
|
|
||||||
let record = {
|
|
||||||
name, host, index, expired, expiredNotify, group, consoleUrl, remark,
|
|
||||||
port: newPort, clientPort, username, authType, password, privateKey, credential, command
|
|
||||||
}
|
|
||||||
if (record[authType]) {
|
|
||||||
const clearTempKey = await RSADecryptAsync(tempKey)
|
const clearTempKey = await RSADecryptAsync(tempKey)
|
||||||
console.log('clearTempKey:', clearTempKey)
|
const clearSSHKey = await AESDecryptAsync(newRecord[authType], clearTempKey)
|
||||||
const clearSSHKey = await AESDecryptAsync(record[authType], clearTempKey)
|
newRecord[authType] = await AESEncryptAsync(clearSSHKey)
|
||||||
console.log(`${ authType }原密文: `, clearSSHKey)
|
|
||||||
record[authType] = await AESEncryptAsync(clearSSHKey)
|
|
||||||
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
|
|
||||||
}
|
}
|
||||||
await hostListDB.insertAsync(record)
|
await hostListDB.insertAsync(newRecord)
|
||||||
res.success()
|
res.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateHost({ res, request }) {
|
async function updateHost({ res, request }) {
|
||||||
let {
|
let {
|
||||||
body: {
|
body
|
||||||
hosts,
|
|
||||||
id,
|
|
||||||
host: newHost, name: newName, index, oldHost, expired, expiredNotify, group, consoleUrl, remark,
|
|
||||||
port, clientPort, username, authType, password, privateKey, credential, command, tempKey
|
|
||||||
}
|
|
||||||
} = request
|
} = request
|
||||||
let isBatch = Array.isArray(hosts)
|
if (typeof body !== 'object') return res.fail({ msg: '参数错误' })
|
||||||
if (isBatch) {
|
const updateFiled = { ...body }
|
||||||
if (!hosts.length) return res.fail({ msg: 'hosts为空' })
|
const { id, authType, tempKey } = updateFiled
|
||||||
let hostList = await hostListDB.findAsync({})
|
if (authType && updateFiled[authType]) {
|
||||||
for (let oldRecord of hostList) {
|
|
||||||
let target = hosts.find(item => item.id === oldRecord._id)
|
|
||||||
if (!target) continue
|
|
||||||
let { authType } = target
|
|
||||||
// 如果存在原认证方式则保存下来
|
|
||||||
if (!target[authType]) {
|
|
||||||
target[authType] = oldRecord[authType]
|
|
||||||
} else {
|
|
||||||
const clearTempKey = await RSADecryptAsync(target.tempKey)
|
|
||||||
// console.log('批量解密tempKey:', clearTempKey)
|
|
||||||
const clearSSHKey = await AESDecryptAsync(target[authType], clearTempKey)
|
|
||||||
// console.log(`${ authType }原密文: `, clearSSHKey)
|
|
||||||
target[authType] = await AESEncryptAsync(clearSSHKey)
|
|
||||||
// console.log(`${ authType }__commonKey加密存储: `, target[authType])
|
|
||||||
}
|
|
||||||
delete target._id
|
|
||||||
delete target.monitorData
|
|
||||||
delete target.tempKey
|
|
||||||
Object.assign(oldRecord, target)
|
|
||||||
await hostListDB.updateAsync({ _id: oldRecord._id }, oldRecord)
|
|
||||||
}
|
|
||||||
return res.success({ msg: '批量修改成功' })
|
|
||||||
}
|
|
||||||
if (!newHost || !newName || !oldHost) return res.fail({ msg: '参数错误' })
|
|
||||||
|
|
||||||
let updateRecord = {
|
|
||||||
name: newName, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark,
|
|
||||||
port, clientPort, username, authType, password, privateKey, credential, command
|
|
||||||
}
|
|
||||||
|
|
||||||
let oldRecord = await hostListDB.findOneAsync({ _id: id })
|
|
||||||
// 如果存在原认证方式则保存下来
|
|
||||||
if (!updateRecord[authType] && oldRecord[authType]) {
|
|
||||||
updateRecord[authType] = oldRecord[authType]
|
|
||||||
} else {
|
|
||||||
const clearTempKey = await RSADecryptAsync(tempKey)
|
const clearTempKey = await RSADecryptAsync(tempKey)
|
||||||
// console.log('clearTempKey:', clearTempKey)
|
const clearSSHKey = await AESDecryptAsync(updateFiled[authType], clearTempKey)
|
||||||
const clearSSHKey = await AESDecryptAsync(updateRecord[authType], clearTempKey)
|
updateFiled[authType] = await AESEncryptAsync(clearSSHKey)
|
||||||
// console.log(`${ authType }原密文: `, clearSSHKey)
|
delete updateFiled.tempKey
|
||||||
updateRecord[authType] = await AESEncryptAsync(clearSSHKey)
|
} else {
|
||||||
// console.log(`${ authType }__commonKey加密存储: `, updateRecord[authType])
|
delete updateFiled.authType
|
||||||
|
delete updateFiled.password
|
||||||
|
delete updateFiled.privateKey
|
||||||
|
delete updateFiled.credential
|
||||||
}
|
}
|
||||||
await hostListDB.updateAsync({ _id: oldRecord._id }, updateRecord)
|
console.log('updateFiled: ', updateFiled)
|
||||||
|
await hostListDB.updateAsync({ _id: id }, { $set: { ...updateFiled } })
|
||||||
res.success({ msg: '修改成功' })
|
res.success({ msg: '修改成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchUpdateHost({ res, request }) {
|
||||||
|
let { updateHosts } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||||
|
if (updateHosts) {
|
||||||
|
await updateHosts({ res, request })
|
||||||
|
} else {
|
||||||
|
return res.fail({ data: false, msg: 'Plus专属功能!' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeHost({ res, request }) {
|
async function removeHost({ res, request }) {
|
||||||
let { body: { ids } } = request
|
let { body: { ids } } = request
|
||||||
if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
|
if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
|
||||||
const numRemoved = await hostListDB.removeAsync({ _id: { $in: ids } }, { multi: true })
|
const numRemoved = await hostListDB.removeAsync({ _id: { $in: ids } }, { multi: true })
|
||||||
// console.log('numRemoved: ', numRemoved)
|
|
||||||
res.success({ data: `已移除,数量: ${ numRemoved }` })
|
res.success({ data: `已移除,数量: ${ numRemoved }` })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,5 +111,6 @@ module.exports = {
|
|||||||
addHost,
|
addHost,
|
||||||
updateHost,
|
updateHost,
|
||||||
removeHost,
|
removeHost,
|
||||||
importHost
|
importHost,
|
||||||
|
batchUpdateHost
|
||||||
}
|
}
|
||||||
|
1
server/app/controller/plus.js
Normal file
1
server/app/controller/plus.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
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
|
@ -1,6 +1,8 @@
|
|||||||
const localShellJson = require('../config/shell.json')
|
const path = require('path')
|
||||||
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
const { randomStr } = require('../utils/tools')
|
const { randomStr } = require('../utils/tools')
|
||||||
const { ScriptsDB } = require('../utils/db-class')
|
const { ScriptsDB } = require('../utils/db-class')
|
||||||
|
const localShellJson = require('../config/shell.json')
|
||||||
const scriptsDB = new ScriptsDB().getInstance()
|
const scriptsDB = new ScriptsDB().getInstance()
|
||||||
|
|
||||||
let localShell = JSON.parse(JSON.stringify(localShellJson)).map((item) => {
|
let localShell = JSON.parse(JSON.stringify(localShellJson)).map((item) => {
|
||||||
@ -44,10 +46,28 @@ const removeScript = async ({ res, request }) => {
|
|||||||
res.success({ data: '移除成功' })
|
res.success({ data: '移除成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const batchRemoveScript = async ({ res, request }) => {
|
||||||
|
let { body: { ids } } = request
|
||||||
|
if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
|
||||||
|
const numRemoved = await scriptsDB.removeAsync({ _id: { $in: ids } }, { multi: true })
|
||||||
|
res.success({ data: `批量移除成功,数量: ${ numRemoved }` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const importScript = async ({ res, request }) => {
|
||||||
|
let { impScript } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||||
|
if (impScript) {
|
||||||
|
await impScript({ res, request })
|
||||||
|
} else {
|
||||||
|
return res.fail({ data: false, msg: 'Plus专属功能!' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
addScript,
|
addScript,
|
||||||
getScriptList,
|
getScriptList,
|
||||||
getLocalScriptList,
|
getLocalScriptList,
|
||||||
updateScriptList,
|
updateScriptList,
|
||||||
removeScript
|
removeScript,
|
||||||
|
batchRemoveScript,
|
||||||
|
importScript
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
|
const path = require('path')
|
||||||
const { RSADecryptAsync, AESEncryptAsync, AESDecryptAsync } = require('../utils/encrypt')
|
const { RSADecryptAsync, AESEncryptAsync, AESDecryptAsync } = require('../utils/encrypt')
|
||||||
const { HostListDB, CredentialsDB } = require('../utils/db-class')
|
const { HostListDB, CredentialsDB } = require('../utils/db-class')
|
||||||
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
const hostListDB = new HostListDB().getInstance()
|
const hostListDB = new HostListDB().getInstance()
|
||||||
const credentialsDB = new CredentialsDB().getInstance()
|
const credentialsDB = new CredentialsDB().getInstance()
|
||||||
|
|
||||||
async function getSSHList({ res }) {
|
async function getSSHList({ res }) {
|
||||||
// console.log('get-host-list')
|
|
||||||
let data = await credentialsDB.findAsync({})
|
let data = await credentialsDB.findAsync({})
|
||||||
data = data?.map(item => {
|
data = data?.map(item => {
|
||||||
const { name, authType, _id: id, date } = item
|
const { name, authType, _id: id, date } = item
|
||||||
@ -83,10 +84,19 @@ const getCommand = async ({ res, request }) => {
|
|||||||
let hostInfo = await hostListDB.findAsync({})
|
let hostInfo = await hostListDB.findAsync({})
|
||||||
let record = hostInfo?.find(item => item._id === hostId)
|
let record = hostInfo?.find(item => item._id === hostId)
|
||||||
consola.info('查询登录后执行的指令:', hostId)
|
consola.info('查询登录后执行的指令:', hostId)
|
||||||
if (!record) return res.fail({ data: false, msg: 'host not found' }) // host不存在
|
if (!record) return res.fail({ data: false, msg: 'host not found' })
|
||||||
const { command } = record
|
const { command } = record
|
||||||
if (!command) return res.success({ data: false }) // command不存在
|
if (!command) return res.success({ data: false })
|
||||||
res.success({ data: command }) // 存在
|
res.success({ data: command })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptPrivateKey = async ({ res, request }) => {
|
||||||
|
let { dePrivateKey } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||||
|
if (dePrivateKey) {
|
||||||
|
await dePrivateKey({ res, request })
|
||||||
|
} else {
|
||||||
|
return res.fail({ data: false, msg: 'Plus专属功能,无法解密私钥!' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@ -94,5 +104,6 @@ module.exports = {
|
|||||||
addSSH,
|
addSSH,
|
||||||
updateSSH,
|
updateSSH,
|
||||||
removeSSH,
|
removeSSH,
|
||||||
getCommand
|
getCommand,
|
||||||
|
decryptPrivateKey
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,10 @@ const QRCode = require('qrcode')
|
|||||||
const { sendNoticeAsync } = require('../utils/notify')
|
const { sendNoticeAsync } = require('../utils/notify')
|
||||||
const { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt } = require('../utils/encrypt')
|
const { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt } = require('../utils/encrypt')
|
||||||
const { getNetIPInfo } = require('../utils/tools')
|
const { getNetIPInfo } = require('../utils/tools')
|
||||||
const { KeyDB, LogDB } = require('../utils/db-class')
|
const { KeyDB, LogDB, PlusDB } = require('../utils/db-class')
|
||||||
const keyDB = new KeyDB().getInstance()
|
const keyDB = new KeyDB().getInstance()
|
||||||
const logDB = new LogDB().getInstance()
|
const logDB = new LogDB().getInstance()
|
||||||
|
const plusDB = new PlusDB().getInstance()
|
||||||
|
|
||||||
const getpublicKey = async ({ res }) => {
|
const getpublicKey = async ({ res }) => {
|
||||||
let { publicKey: data } = await keyDB.findOneAsync({})
|
let { publicKey: data } = await keyDB.findOneAsync({})
|
||||||
@ -164,6 +165,13 @@ const disableMFA2 = async ({ res }) => {
|
|||||||
res.success({ msg: 'success' })
|
res.success({ msg: 'success' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPlusInfo = async ({ res }) => {
|
||||||
|
let data = await plusDB.findOneAsync({})
|
||||||
|
delete data?._id
|
||||||
|
delete data?.decryptKey
|
||||||
|
res.success({ data, msg: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
login,
|
login,
|
||||||
getpublicKey,
|
getpublicKey,
|
||||||
@ -172,5 +180,6 @@ module.exports = {
|
|||||||
getMFA2Status,
|
getMFA2Status,
|
||||||
getMFA2Code,
|
getMFA2Code,
|
||||||
enableMFA2,
|
enableMFA2,
|
||||||
disableMFA2
|
disableMFA2,
|
||||||
|
getPlusInfo
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
const consola = require('consola')
|
|
||||||
global.consola = consola
|
|
||||||
const { httpServer } = require('./server')
|
const { httpServer } = require('./server')
|
||||||
const initDB = require('./db')
|
const initDB = require('./db')
|
||||||
const scheduleJob = require('./schedule')
|
const scheduleJob = require('./schedule')
|
||||||
|
const getLicenseInfo = require('./utils/get-plus')
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await initDB()
|
await initDB()
|
||||||
httpServer()
|
httpServer()
|
||||||
scheduleJob()
|
scheduleJob()
|
||||||
|
getLicenseInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
@ -3,27 +3,28 @@ const { outDir, recordLog } = require('../config').logConfig
|
|||||||
|
|
||||||
log4js.configure({
|
log4js.configure({
|
||||||
appenders: {
|
appenders: {
|
||||||
// 控制台输出
|
console: {
|
||||||
out: {
|
|
||||||
type: 'stdout',
|
type: 'stdout',
|
||||||
layout: {
|
layout: {
|
||||||
type: 'colored'
|
type: 'pattern',
|
||||||
|
pattern: '%[%d{yyyy-MM-dd hh:mm:ss.SSS} [%p] -%] %m'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 保存日志文件
|
|
||||||
cheese: {
|
cheese: {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
maxLogSize: 512*1024, // unit: bytes 1KB = 1024bytes
|
maxLogSize: 10 * 1024 * 1024, // unit: bytes 1KB = 1024bytes
|
||||||
filename: `${ outDir }/receive.log`
|
filename: `${ outDir }/receive.log`,
|
||||||
|
backups: 10,
|
||||||
|
compress: true,
|
||||||
|
keepFileExt: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
default: {
|
default: {
|
||||||
appenders: [ 'out', 'cheese' ], // 配置
|
appenders: ['console', 'cheese'],
|
||||||
level: 'info' // 只输出info以上级别的日志
|
level: 'debug'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// pm2: true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const logger = log4js.getLogger()
|
const logger = log4js.getLogger()
|
||||||
@ -56,3 +57,6 @@ const useLog = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = useLog()
|
module.exports = useLog()
|
||||||
|
|
||||||
|
// 可以先测试一下日志是否正常工作
|
||||||
|
logger.info('日志系统启动')
|
@ -1,9 +1,9 @@
|
|||||||
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh')
|
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey } = require('../controller/ssh')
|
||||||
const { getHostList, addHost, updateHost, removeHost, importHost } = require('../controller/host')
|
const { getHostList, addHost, updateHost, batchUpdateHost, removeHost, importHost } = require('../controller/host')
|
||||||
const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2 } = require('../controller/user')
|
const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo } = require('../controller/user')
|
||||||
const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify')
|
const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify')
|
||||||
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
|
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
|
||||||
const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript } = require('../controller/scripts')
|
const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript, batchRemoveScript, importScript } = require('../controller/scripts')
|
||||||
const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
|
const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
|
||||||
const { getLog } = require('../controller/log')
|
const { getLog } = require('../controller/log')
|
||||||
|
|
||||||
@ -32,6 +32,11 @@ const ssh = [
|
|||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/command',
|
path: '/command',
|
||||||
controller: getCommand
|
controller: getCommand
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/decrypt-private-key',
|
||||||
|
controller: decryptPrivateKey
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const host = [
|
const host = [
|
||||||
@ -50,6 +55,11 @@ const host = [
|
|||||||
path: '/host-save',
|
path: '/host-save',
|
||||||
controller: updateHost
|
controller: updateHost
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'put',
|
||||||
|
path: '/batch-update-host',
|
||||||
|
controller: batchUpdateHost
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/host-remove',
|
path: '/host-remove',
|
||||||
@ -101,6 +111,11 @@ const user = [
|
|||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/mfa2-disable',
|
path: '/mfa2-disable',
|
||||||
controller: disableMFA2
|
controller: disableMFA2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/plus-info',
|
||||||
|
controller: getPlusInfo
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const notify = [
|
const notify = [
|
||||||
@ -170,10 +185,20 @@ const scripts = [
|
|||||||
path: '/script/:id',
|
path: '/script/:id',
|
||||||
controller: removeScript
|
controller: removeScript
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/batch-remove-script',
|
||||||
|
controller: batchRemoveScript
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'put',
|
method: 'put',
|
||||||
path: '/script/:id',
|
path: '/script/:id',
|
||||||
controller: updateScriptList
|
controller: updateScriptList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/import-script',
|
||||||
|
controller: importScript
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1,33 +0,0 @@
|
|||||||
const schedule = require('node-schedule')
|
|
||||||
const { sendNoticeAsync } = require('../utils/notify')
|
|
||||||
const { formatTimestamp } = require('../utils/tools')
|
|
||||||
const { HostListDB } = require('../utils/db-class')
|
|
||||||
const hostListDB = new HostListDB().getInstance()
|
|
||||||
|
|
||||||
const expiredNotifyJob = async () => {
|
|
||||||
consola.info('=====开始检测服务器到期时间=====', new Date())
|
|
||||||
const hostList = await hostListDB.findAsync({})
|
|
||||||
for (const item of hostList) {
|
|
||||||
if (!item.expiredNotify) continue
|
|
||||||
const { host, name, expired, consoleUrl } = item
|
|
||||||
const restDay = Number(((expired - Date.now()) / (1000 * 60 * 60 * 24)).toFixed(1))
|
|
||||||
console.log(Date.now(), restDay)
|
|
||||||
let title = '服务器到期提醒'
|
|
||||||
let content = `别名: ${ name }\nIP: ${ host }\n到期时间:${ formatTimestamp(expired, 'week') }\n控制台: ${ consoleUrl || '未填写' }`
|
|
||||||
if (0 <= restDay && restDay <= 1) {
|
|
||||||
let temp = '有服务器将在一天后到期,请关注\n'
|
|
||||||
sendNoticeAsync('host_expired', title, temp + content)
|
|
||||||
} else if (3 <= restDay && restDay < 4) {
|
|
||||||
let temp = '有服务器将在三天后到期,请关注\n'
|
|
||||||
sendNoticeAsync('host_expired', title, temp + content)
|
|
||||||
} else if (7 <= restDay && restDay < 8) {
|
|
||||||
let temp = '有服务器将在七天后到期,请关注\n'
|
|
||||||
sendNoticeAsync('host_expired', title, temp + content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = () => {
|
|
||||||
// 每天中午12点执行一次。
|
|
||||||
schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob)
|
|
||||||
}
|
|
@ -1,5 +1,32 @@
|
|||||||
const expiredNotify = require('./expired-notify')
|
const schedule = require('node-schedule')
|
||||||
|
const { sendNoticeAsync } = require('../utils/notify')
|
||||||
|
const { formatTimestamp } = require('../utils/tools')
|
||||||
|
const { HostListDB } = require('../utils/db-class')
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
|
||||||
|
const expiredNotifyJob = async () => {
|
||||||
|
consola.info('=====开始检测服务器到期时间=====', new Date())
|
||||||
|
const hostList = await hostListDB.findAsync({})
|
||||||
|
for (const item of hostList) {
|
||||||
|
if (!item.expiredNotify) continue
|
||||||
|
const { host, name, expired, consoleUrl } = item
|
||||||
|
const restDay = Number(((expired - Date.now()) / (1000 * 60 * 60 * 24)).toFixed(1))
|
||||||
|
console.log(Date.now(), restDay)
|
||||||
|
let title = '服务器到期提醒'
|
||||||
|
let content = `别名: ${ name }\nIP: ${ host }\n到期时间:${ formatTimestamp(expired, 'week') }\n控制台: ${ consoleUrl || '未填写' }`
|
||||||
|
if (0 <= restDay && restDay <= 1) {
|
||||||
|
let temp = '有服务器将在一天后到期,请关注\n'
|
||||||
|
sendNoticeAsync('host_expired', title, temp + content)
|
||||||
|
} else if (3 <= restDay && restDay < 4) {
|
||||||
|
let temp = '有服务器将在三天后到期,请关注\n'
|
||||||
|
sendNoticeAsync('host_expired', title, temp + content)
|
||||||
|
} else if (7 <= restDay && restDay < 8) {
|
||||||
|
let temp = '有服务器将在七天后到期,请关注\n'
|
||||||
|
sendNoticeAsync('host_expired', title, temp + content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = () => {
|
module.exports = () => {
|
||||||
expiredNotify()
|
schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob)
|
||||||
}
|
}
|
||||||
|
1
server/app/socket/plus.js
Normal file
1
server/app/socket/plus.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
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
|
@ -1,108 +1,101 @@
|
|||||||
|
const path = require('path')
|
||||||
const { Server } = require('socket.io')
|
const { Server } = require('socket.io')
|
||||||
const { Client: SSHClient } = require('ssh2')
|
const { Client: SSHClient } = require('ssh2')
|
||||||
const { verifyAuthSync } = require('../utils/verify-auth')
|
const { verifyAuthSync } = require('../utils/verify-auth')
|
||||||
const { AESDecryptAsync } = require('../utils/encrypt')
|
|
||||||
const { sendNoticeAsync } = require('../utils/notify')
|
const { sendNoticeAsync } = require('../utils/notify')
|
||||||
const { isAllowedIp, ping } = require('../utils/tools')
|
const { isAllowedIp, ping } = require('../utils/tools')
|
||||||
|
const { AESDecryptAsync } = require('../utils/encrypt')
|
||||||
const { HostListDB, CredentialsDB } = require('../utils/db-class')
|
const { HostListDB, CredentialsDB } = require('../utils/db-class')
|
||||||
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
const hostListDB = new HostListDB().getInstance()
|
const hostListDB = new HostListDB().getInstance()
|
||||||
const credentialsDB = new CredentialsDB().getInstance()
|
const credentialsDB = new CredentialsDB().getInstance()
|
||||||
|
|
||||||
function createInteractiveShell(socket, sshClient) {
|
async function getConnectionOptions(hostId) {
|
||||||
|
const hostInfo = await hostListDB.findOneAsync({ _id: hostId })
|
||||||
|
if (!hostInfo) throw new Error(`Host with ID ${ hostId } not found`)
|
||||||
|
let { authType, host, port, username, name } = hostInfo
|
||||||
|
let authInfo = { host, port, username }
|
||||||
|
try {
|
||||||
|
if (authType === 'credential') {
|
||||||
|
let credentialId = await AESDecryptAsync(hostInfo[authType])
|
||||||
|
const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId })
|
||||||
|
authInfo.authType = sshRecord.authType
|
||||||
|
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
|
||||||
|
} else {
|
||||||
|
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
|
||||||
|
}
|
||||||
|
return { authInfo, name }
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`解密认证信息失败: ${ err.message }`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInteractiveShell(socket, targetSSHClient) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
sshClient.shell({ term: 'xterm-color' }, (err, stream) => {
|
targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => {
|
||||||
resolve(stream)
|
resolve(stream)
|
||||||
if (err) return socket.emit('output', err.toString())
|
if (err) return socket.emit('output', err.toString())
|
||||||
// 终端输出
|
|
||||||
stream
|
stream
|
||||||
.on('data', (data) => {
|
.on('data', (data) => {
|
||||||
socket.emit('output', data.toString())
|
socket.emit('output', data.toString())
|
||||||
})
|
})
|
||||||
.on('close', () => {
|
.on('close', () => {
|
||||||
consola.info('交互终端已关闭')
|
consola.info('交互终端已关闭')
|
||||||
sshClient.end()
|
targetSSHClient.end()
|
||||||
})
|
})
|
||||||
socket.emit('connect_shell_success') // 已连接终端,web端可以执行指令了
|
socket.emit('connect_shell_success') // 已连接终端,web端可以执行指令了
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// function execShell(sshClient, command = '', callback) {
|
async function createTerminal(hostId, socket, targetSSHClient) {
|
||||||
// if (!command) return
|
|
||||||
// let result = ''
|
|
||||||
// sshClient.exec(`source ~/.bashrc && ${ command }`, (err, stream) => {
|
|
||||||
// if (err) return callback(err.toString())
|
|
||||||
// stream
|
|
||||||
// .on('data', (data) => {
|
|
||||||
// result += data.toString()
|
|
||||||
// })
|
|
||||||
// .stderr
|
|
||||||
// .on('data', (data) => {
|
|
||||||
// result += data.toString()
|
|
||||||
// })
|
|
||||||
// .on('close', () => {
|
|
||||||
// consola.info('一次性指令执行完成:', command)
|
|
||||||
// callback(result)
|
|
||||||
// })
|
|
||||||
// .on('error', (error) => {
|
|
||||||
// console.log('Error:', error.toString())
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
async function createTerminal(hostId, socket, sshClient) {
|
|
||||||
// eslint-disable-next-line no-async-promise-executor
|
|
||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
const hostList = await hostListDB.findAsync({})
|
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
|
||||||
const targetHostInfo = hostList.find(item => item._id === hostId) || {}
|
if (!targetHostInfo) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
|
||||||
let { authType, host, port, username, name } = targetHostInfo
|
let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||||
if (!host) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
|
let { authType, host, port, username, name, jumpHosts } = targetHostInfo
|
||||||
let authInfo = { host, port, username }
|
|
||||||
// 统一使用commonKey解密
|
|
||||||
try {
|
try {
|
||||||
// 解密放到try里面,防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
|
let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId)
|
||||||
if (authType === 'credential') {
|
let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket))
|
||||||
let credentialId = await AESDecryptAsync(targetHostInfo[authType])
|
if (jumpHostResult) {
|
||||||
const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId })
|
targetConnectionOptions.sock = jumpHostResult.sock
|
||||||
authInfo.authType = sshRecord.authType
|
|
||||||
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
|
|
||||||
} else {
|
|
||||||
authInfo[authType] = await AESDecryptAsync(targetHostInfo[authType])
|
|
||||||
}
|
}
|
||||||
consola.info('准备连接终端:', host)
|
|
||||||
// targetHostInfo[targetHostInfo.authType] = await AESDecryptAsync(targetHostInfo[targetHostInfo.authType])
|
socket.emit('terminal_print_info', `准备连接目标终端: ${ name } - ${ host }`)
|
||||||
|
socket.emit('terminal_print_info', `连接信息: ssh ${ username }@${ host } -p ${ port } -> ${ authType }`)
|
||||||
|
|
||||||
|
consola.info('准备连接目标终端:', host)
|
||||||
consola.log('连接信息', { username, port, authType })
|
consola.log('连接信息', { username, port, authType })
|
||||||
sshClient
|
let closeNoticeFlag = false // 避免重复发送通知
|
||||||
.on('ready', async() => {
|
targetSSHClient
|
||||||
|
.on('ready', async () => {
|
||||||
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录成功`)
|
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录成功`)
|
||||||
|
socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`)
|
||||||
consola.success('终端连接成功:', host)
|
consola.success('终端连接成功:', host)
|
||||||
socket.emit('connect_terminal_success', `终端连接成功:${ host }`)
|
socket.emit('connect_terminal_success', `终端连接成功:${ host }`)
|
||||||
let stream = await createInteractiveShell(socket, sshClient)
|
let stream = await createInteractiveShell(socket, targetSSHClient)
|
||||||
resolve(stream)
|
resolve(stream)
|
||||||
// execShell(sshClient, 'history', (data) => {
|
|
||||||
// data = data.split('\n').filter(item => item)
|
|
||||||
// console.log(data)
|
|
||||||
// socket.emit('terminal_command_history', data)
|
|
||||||
// })
|
|
||||||
})
|
})
|
||||||
.on('close', () => {
|
.on('close', (err) => {
|
||||||
consola.info('终端连接断开close: ', host)
|
if (closeNoticeFlag) return closeNoticeFlag = false
|
||||||
socket.emit('connect_close')
|
const closeReason = err ? '发生错误导致连接断开' : '正常断开连接'
|
||||||
|
consola.info(`终端连接断开(${ closeReason }): ${ host }`)
|
||||||
|
socket.emit('connect_close', { reason: closeReason })
|
||||||
})
|
})
|
||||||
.on('error', (err) => {
|
.on('error', (err) => {
|
||||||
consola.log(err)
|
closeNoticeFlag = true
|
||||||
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录失败`)
|
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录失败`)
|
||||||
consola.error('连接终端失败:', host, err.message)
|
consola.error('连接终端失败:', host, err.message)
|
||||||
socket.emit('connect_fail', err.message)
|
socket.emit('connect_terminal_fail', err.message)
|
||||||
})
|
})
|
||||||
.connect({
|
.connect({
|
||||||
...authInfo
|
...targetConnectionOptions
|
||||||
// debug: (info) => console.log(info)
|
// debug: (info) => console.log(info)
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
consola.error('创建终端失败: ', host, err.message)
|
consola.error('创建终端失败: ', host, err.message)
|
||||||
socket.emit('create_fail', err.message)
|
socket.emit('create_terminal_fail', err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -111,11 +104,15 @@ module.exports = (httpServer) => {
|
|||||||
const serverIo = new Server(httpServer, {
|
const serverIo = new Server(httpServer, {
|
||||||
path: '/terminal',
|
path: '/terminal',
|
||||||
cors: {
|
cors: {
|
||||||
origin: '*' // 'http://localhost:8080'
|
origin: '*'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let connectionCount = 0
|
||||||
|
|
||||||
serverIo.on('connection', (socket) => {
|
serverIo.on('connection', (socket) => {
|
||||||
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
connectionCount++
|
||||||
|
consola.success(`terminal websocket 已连接 - 当前连接数: ${ connectionCount }`)
|
||||||
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
||||||
if (!isAllowedIp(requestIP)) {
|
if (!isAllowedIp(requestIP)) {
|
||||||
socket.emit('ip_forbidden', 'IP地址不在白名单中')
|
socket.emit('ip_forbidden', 'IP地址不在白名单中')
|
||||||
@ -123,7 +120,7 @@ module.exports = (httpServer) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
consola.success('terminal websocket 已连接')
|
consola.success('terminal websocket 已连接')
|
||||||
let sshClient = null
|
let targetSSHClient = null
|
||||||
socket.on('create', async ({ hostId, token }) => {
|
socket.on('create', async ({ hostId, token }) => {
|
||||||
const { code } = await verifyAuthSync(token, requestIP)
|
const { code } = await verifyAuthSync(token, requestIP)
|
||||||
if (code !== 1) {
|
if (code !== 1) {
|
||||||
@ -131,47 +128,21 @@ module.exports = (httpServer) => {
|
|||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sshClient = new SSHClient()
|
targetSSHClient = new SSHClient()
|
||||||
|
|
||||||
// 尝试手动断开调试,再次连接后终端输出内容为4份相同的输出,导致异常
|
|
||||||
// setTimeout(() => {
|
|
||||||
// sshClient.end()
|
|
||||||
// }, 3000)
|
|
||||||
let stream = null
|
let stream = null
|
||||||
|
|
||||||
function listenerInput(key) {
|
function listenerInput(key) {
|
||||||
if (sshClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
|
if (targetSSHClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
|
||||||
stream && stream.write(key)
|
stream && stream.write(key)
|
||||||
}
|
}
|
||||||
function resizeShell({ rows, cols }) {
|
function resizeShell({ rows, cols }) {
|
||||||
// consola.info('更改tty终端行&列: ', { rows, cols })
|
|
||||||
stream && stream.setWindow(rows, cols)
|
stream && stream.setWindow(rows, cols)
|
||||||
}
|
}
|
||||||
socket.on('input', listenerInput)
|
socket.on('input', listenerInput)
|
||||||
socket.on('resize', resizeShell)
|
socket.on('resize', resizeShell)
|
||||||
|
stream = await createTerminal(hostId, socket, targetSSHClient)
|
||||||
// 重连
|
|
||||||
socket.on('reconnect_terminal', async () => {
|
|
||||||
consola.info('重连终端: ', hostId)
|
|
||||||
socket.off('input', listenerInput) // 取消监听,重新注册监听,操作新的stream
|
|
||||||
socket.off('resize', resizeShell)
|
|
||||||
sshClient?.end()
|
|
||||||
sshClient?.destroy()
|
|
||||||
sshClient = null
|
|
||||||
stream = null
|
|
||||||
setTimeout(async () => {
|
|
||||||
// 初始化新的SSH客户端对象
|
|
||||||
sshClient = new SSHClient()
|
|
||||||
stream = await createTerminal(hostId, socket, sshClient)
|
|
||||||
socket.emit('reconnect_terminal_success')
|
|
||||||
socket.on('input', listenerInput)
|
|
||||||
socket.on('resize', resizeShell)
|
|
||||||
}, 3000)
|
|
||||||
})
|
|
||||||
stream = await createTerminal(hostId, socket, sshClient)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('get_ping',async (ip) => {
|
socket.on('get_ping', async (ip) => {
|
||||||
try {
|
try {
|
||||||
socket.emit('ping_data', await ping(ip, 2500))
|
socket.emit('ping_data', await ping(ip, 2500))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -180,7 +151,10 @@ module.exports = (httpServer) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
consola.info('终端socket连接断开:', reason)
|
connectionCount--
|
||||||
|
consola.info(`终端socket连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.getConnectionOptions = getConnectionOptions
|
||||||
|
@ -8,7 +8,8 @@ const {
|
|||||||
groupConfDBPath,
|
groupConfDBPath,
|
||||||
scriptsDBPath,
|
scriptsDBPath,
|
||||||
onekeyDBPath,
|
onekeyDBPath,
|
||||||
logDBPath
|
logDBPath,
|
||||||
|
plusDBPath
|
||||||
} = require('../config')
|
} = require('../config')
|
||||||
|
|
||||||
module.exports.KeyDB = class KeyDB {
|
module.exports.KeyDB = class KeyDB {
|
||||||
@ -118,3 +119,14 @@ module.exports.LogDB = class LogDB {
|
|||||||
return LogDB.instance
|
return LogDB.instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.PlusDB = class PlusDB {
|
||||||
|
constructor() {
|
||||||
|
if (!PlusDB.instance) {
|
||||||
|
PlusDB.instance = new Datastore({ filename: plusDBPath, autoload: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getInstance() {
|
||||||
|
return PlusDB.instance
|
||||||
|
}
|
||||||
|
}
|
53
server/app/utils/decrypt-file.js
Normal file
53
server/app/utils/decrypt-file.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
const fs = require('fs-extra')
|
||||||
|
const path = require('path')
|
||||||
|
const CryptoJS = require('crypto-js')
|
||||||
|
const { AESDecryptAsync } = require('./encrypt')
|
||||||
|
const { PlusDB } = require('./db-class')
|
||||||
|
const plusDB = new PlusDB().getInstance()
|
||||||
|
|
||||||
|
function decryptAndExecuteAsync(plusPath) {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
try {
|
||||||
|
let { decryptKey } = await plusDB.findOneAsync({})
|
||||||
|
if (!decryptKey) {
|
||||||
|
throw new Error('缺少解密密钥')
|
||||||
|
}
|
||||||
|
decryptKey = await AESDecryptAsync(decryptKey)
|
||||||
|
const encryptedContent = fs.readFileSync(plusPath, 'utf-8')
|
||||||
|
const bytes = CryptoJS.AES.decrypt(encryptedContent, decryptKey)
|
||||||
|
const decryptedContent = bytes.toString(CryptoJS.enc.Utf8)
|
||||||
|
if (!decryptedContent) {
|
||||||
|
throw new Error('解密失败,请检查密钥是否正确')
|
||||||
|
}
|
||||||
|
const customRequire = (modulePath) => {
|
||||||
|
if (modulePath.startsWith('.')) {
|
||||||
|
const absolutePath = path.resolve(path.dirname(plusPath), modulePath)
|
||||||
|
return require(absolutePath)
|
||||||
|
}
|
||||||
|
return require(modulePath)
|
||||||
|
}
|
||||||
|
const module = {
|
||||||
|
exports: {},
|
||||||
|
require: customRequire,
|
||||||
|
__filename: plusPath,
|
||||||
|
__dirname: path.dirname(plusPath)
|
||||||
|
}
|
||||||
|
const wrapper = Function('module', 'exports', 'require', '__filename', '__dirname',
|
||||||
|
decryptedContent + '\n return module.exports;'
|
||||||
|
)
|
||||||
|
const exports = wrapper(
|
||||||
|
module,
|
||||||
|
module.exports,
|
||||||
|
customRequire,
|
||||||
|
module.__filename,
|
||||||
|
module.__dirname
|
||||||
|
)
|
||||||
|
resolve(exports)
|
||||||
|
} catch (error) {
|
||||||
|
consola.info('解锁plus功能失败: ', error.message)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = decryptAndExecuteAsync
|
93
server/app/utils/get-plus.js
Normal file
93
server/app/utils/get-plus.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
const schedule = require('node-schedule')
|
||||||
|
const { getLocalNetIP } = require('./tools')
|
||||||
|
const { AESEncryptAsync } = require('./encrypt')
|
||||||
|
const version = require('../../package.json').version
|
||||||
|
|
||||||
|
async function getLicenseInfo() {
|
||||||
|
let key = process.env.PLUS_KEY
|
||||||
|
if (!key || typeof key !== 'string' || key.length < 20) return
|
||||||
|
let ip = ''
|
||||||
|
if (global.serverIp && (Date.now() - global.getServerIpLastTime) / 1000 / 60 < 60) {
|
||||||
|
ip = global.serverIp
|
||||||
|
consola.log('get server ip by cache: ', ip)
|
||||||
|
} else {
|
||||||
|
ip = await getLocalNetIP()
|
||||||
|
global.serverIp = ip
|
||||||
|
global.getServerIpLastTime = Date.now()
|
||||||
|
consola.log('get server ip by net: ', ip)
|
||||||
|
}
|
||||||
|
if (!ip) {
|
||||||
|
consola.error('activate plus failed: get public ip failed')
|
||||||
|
global.serverIp = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let response
|
||||||
|
let method = 'POST'
|
||||||
|
let body = JSON.stringify({ ip, key, version })
|
||||||
|
let headers = { 'Content-Type': 'application/json' }
|
||||||
|
let timeout = 10000
|
||||||
|
try {
|
||||||
|
response = await fetch('https://en1.221022.xyz/api/licenses/activate', {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
timeout
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok && (response.status !== 403)) {
|
||||||
|
throw new Error('port1 error')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
consola.log('retry to activate plus by backup server')
|
||||||
|
response = await fetch('https://en2.221022.xyz/api/licenses/activate', {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
timeout
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
consola.log('activate plus failed: ', response.status)
|
||||||
|
if (response.status === 403) {
|
||||||
|
const errMsg = await response.json()
|
||||||
|
throw { errMsg, clear: true }
|
||||||
|
}
|
||||||
|
throw Error({ errMsg: `HTTP error! status: ${ response.status }` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { success, data } = await response.json()
|
||||||
|
if (success) {
|
||||||
|
let { decryptKey, expiryDate, usedIPCount, maxIPs, usedIPs } = data
|
||||||
|
decryptKey = await AESEncryptAsync(decryptKey)
|
||||||
|
consola.success('activate plus success')
|
||||||
|
const { PlusDB } = require('./db-class')
|
||||||
|
const plusData = { key, decryptKey, expiryDate, usedIPCount, maxIPs, usedIPs }
|
||||||
|
const plusDB = new PlusDB().getInstance()
|
||||||
|
let count = await plusDB.countAsync({})
|
||||||
|
if (count === 0) {
|
||||||
|
await plusDB.insertAsync(plusData)
|
||||||
|
} else {
|
||||||
|
await plusDB.removeAsync({}, { multi: true })
|
||||||
|
await plusDB.insertAsync(plusData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
consola.error(`activate plus failed: ${ error.message || error.errMsg?.message }`)
|
||||||
|
if (error.clear) {
|
||||||
|
const { PlusDB } = require('./db-class')
|
||||||
|
const plusDB = new PlusDB().getInstance()
|
||||||
|
await plusDB.removeAsync({}, { multi: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomHour = Math.floor(Math.random() * 24)
|
||||||
|
const randomMinute = Math.floor(Math.random() * 60)
|
||||||
|
const randomDay = Math.floor(Math.random() * 7)
|
||||||
|
const cronExpression = `${ randomMinute } ${ randomHour } * * ${ randomDay }`
|
||||||
|
schedule.scheduleJob(cronExpression, getLicenseInfo)
|
||||||
|
|
||||||
|
module.exports = getLicenseInfo
|
@ -89,6 +89,35 @@ const getNetIPInfo = async (searchIp = '') => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getLocalNetIP = async () => {
|
||||||
|
try {
|
||||||
|
let ipUrls = [
|
||||||
|
'http://whois.pconline.com.cn/ipJson.jsp?json=true',
|
||||||
|
'https://www.ip.cn/api/index?ip=&type=0',
|
||||||
|
'https://freeipapi.com/api/json'
|
||||||
|
]
|
||||||
|
let result = await Promise.allSettled(ipUrls.map(url => axios.get(url)))
|
||||||
|
let [pconline, ipCN, freeipapi] = result
|
||||||
|
if (pconline.status === 'fulfilled') {
|
||||||
|
let ip = pconline.value?.data?.ip
|
||||||
|
if (ip) return ip
|
||||||
|
}
|
||||||
|
if (ipCN.status === 'fulfilled') {
|
||||||
|
let ip = ipCN.value?.data?.ip
|
||||||
|
consola.log('ipCN:', ip)
|
||||||
|
if (ip) return ip
|
||||||
|
}
|
||||||
|
if (freeipapi.status === 'fulfilled') {
|
||||||
|
let ip = pconline.value?.data?.ipAddress
|
||||||
|
if (ip) return ip
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getIpInfo Error: ', error?.message || error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isLocalIP(ip) {
|
function isLocalIP(ip) {
|
||||||
// Check if IPv4 or IPv6 address
|
// Check if IPv4 or IPv6 address
|
||||||
const isIPv4 = net.isIPv4(ip)
|
const isIPv4 = net.isIPv4(ip)
|
||||||
@ -159,7 +188,7 @@ const isIP = (ip = '') => {
|
|||||||
return isIPv4.test(ip) || isIPv6.test(ip)
|
return isIPv4.test(ip) || isIPv6.test(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomStr = (len) =>{
|
const randomStr = (len) => {
|
||||||
len = len || 16
|
len = len || 16
|
||||||
let str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678',
|
let str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678',
|
||||||
a = str.length,
|
a = str.length,
|
||||||
@ -178,7 +207,7 @@ const getUTCDate = (num = 8) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatTimestamp = (timestamp = Date.now(), format = 'time') => {
|
const formatTimestamp = (timestamp = Date.now(), format = 'time') => {
|
||||||
if (typeof(timestamp) !== 'number') return '--'
|
if (typeof (timestamp) !== 'number') return '--'
|
||||||
let date = new Date(timestamp)
|
let date = new Date(timestamp)
|
||||||
let padZero = (num) => String(num).padStart(2, '0')
|
let padZero = (num) => String(num).padStart(2, '0')
|
||||||
let year = date.getFullYear()
|
let year = date.getFullYear()
|
||||||
@ -187,7 +216,7 @@ const formatTimestamp = (timestamp = Date.now(), format = 'time') => {
|
|||||||
let hours = padZero(date.getHours())
|
let hours = padZero(date.getHours())
|
||||||
let minute = padZero(date.getMinutes())
|
let minute = padZero(date.getMinutes())
|
||||||
let second = padZero(date.getSeconds())
|
let second = padZero(date.getSeconds())
|
||||||
let weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六' ]
|
let weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
let week = weekday[date.getDay()]
|
let week = weekday[date.getDay()]
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'date':
|
case 'date':
|
||||||
@ -284,6 +313,7 @@ const ping = (ip, timeout = 5000) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getNetIPInfo,
|
getNetIPInfo,
|
||||||
|
getLocalNetIP,
|
||||||
throwError,
|
throwError,
|
||||||
isIP,
|
isIP,
|
||||||
randomStr,
|
randomStr,
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
|
const consola = require('consola')
|
||||||
|
global.consola = consola
|
||||||
require('dotenv').config()
|
require('dotenv').config()
|
||||||
require('./app/main.js')
|
require('./app/main.js')
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "1.0.0",
|
"version": "3.0.0",
|
||||||
"description": "easynode-server",
|
"description": "easynode-server",
|
||||||
"bin": "./bin/www",
|
"bin": "./bin/www",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "2.3.0",
|
"version": "3.0.0",
|
||||||
"description": "easynode-web",
|
"description": "easynode-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -19,12 +19,15 @@ export default {
|
|||||||
removeSSH(id) {
|
removeSSH(id) {
|
||||||
return axios({ url: `/remove-ssh/${ id }`, method: 'delete' })
|
return axios({ url: `/remove-ssh/${ id }`, method: 'delete' })
|
||||||
},
|
},
|
||||||
// existSSH(host) {
|
getPlusInfo() {
|
||||||
// return axios({ url: '/exist-ssh', method: 'post', data: { host } })
|
return axios({ url: '/plus-info', method: 'get' })
|
||||||
// },
|
},
|
||||||
getCommand(hostId) {
|
getCommand(hostId) {
|
||||||
return axios({ url: '/command', method: 'get', params: { hostId } })
|
return axios({ url: '/command', method: 'get', params: { hostId } })
|
||||||
},
|
},
|
||||||
|
decryptPrivateKey(data) {
|
||||||
|
return axios({ url: '/decrypt-private-key', method: 'post', data })
|
||||||
|
},
|
||||||
getHostList() {
|
getHostList() {
|
||||||
return axios({ url: '/host-list', method: 'get' })
|
return axios({ url: '/host-list', method: 'get' })
|
||||||
},
|
},
|
||||||
@ -34,6 +37,9 @@ export default {
|
|||||||
updateHost(data) {
|
updateHost(data) {
|
||||||
return axios({ url: '/host-save', method: 'put', data })
|
return axios({ url: '/host-save', method: 'put', data })
|
||||||
},
|
},
|
||||||
|
batchUpdateHost(data) {
|
||||||
|
return axios({ url: '/batch-update-host', method: 'put', data })
|
||||||
|
},
|
||||||
removeHost(data) {
|
removeHost(data) {
|
||||||
return axios({ url: '/host-remove', method: 'post', data })
|
return axios({ url: '/host-remove', method: 'post', data })
|
||||||
},
|
},
|
||||||
@ -88,8 +94,11 @@ export default {
|
|||||||
deleteGroup(id) {
|
deleteGroup(id) {
|
||||||
return axios({ url: `/group/${ id }`, method: 'delete' })
|
return axios({ url: `/group/${ id }`, method: 'delete' })
|
||||||
},
|
},
|
||||||
getScriptList() {
|
getScriptList(params = {}) {
|
||||||
return axios({ url: '/script', method: 'get' })
|
return axios({ url: '/script', method: 'get', params })
|
||||||
|
},
|
||||||
|
importScript(data) {
|
||||||
|
return axios({ url: '/import-script', method: 'post', data })
|
||||||
},
|
},
|
||||||
getLocalScriptList() {
|
getLocalScriptList() {
|
||||||
return axios({ url: '/local-script', method: 'get' })
|
return axios({ url: '/local-script', method: 'get' })
|
||||||
@ -103,6 +112,9 @@ export default {
|
|||||||
deleteScript(id) {
|
deleteScript(id) {
|
||||||
return axios({ url: `/script/${ id }`, method: 'delete' })
|
return axios({ url: `/script/${ id }`, method: 'delete' })
|
||||||
},
|
},
|
||||||
|
batchRemoveScript(data) {
|
||||||
|
return axios({ url: '/batch-remove-script', method: 'post', data })
|
||||||
|
},
|
||||||
getOnekeyRecord() {
|
getOnekeyRecord() {
|
||||||
return axios({ url: '/onekey', method: 'get' })
|
return axios({ url: '/onekey', method: 'get' })
|
||||||
},
|
},
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, getCurrentInstance } from 'vue'
|
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import { ref, getCurrentInstance } from 'vue'
|
||||||
const { proxy: { $store } } = getCurrentInstance()
|
const { proxy: { $store } } = getCurrentInstance()
|
||||||
|
|
||||||
const locale = ref(zhCn)
|
const locale = ref(zhCn)
|
||||||
|
BIN
web/src/assets/plus.png
Normal file
BIN
web/src/assets/plus.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.9 KiB |
42
web/src/components/common/PlusSupportTip.vue
Normal file
42
web/src/components/common/PlusSupportTip.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<el-tooltip
|
||||||
|
:disabled="isPlusActive"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="plus_support_tip">
|
||||||
|
此功能需要激活Plus后使用,
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handlePlusSupport"
|
||||||
|
>
|
||||||
|
去激活
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
|
const { proxy: { $store } } = getCurrentInstance()
|
||||||
|
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
|
const handlePlusSupport = () => {
|
||||||
|
window.open('https://en.221022.xyz/buy-plus', '_blank')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.plus_support_tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
@ -18,7 +18,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, markRaw, getCurrentInstance, computed, watchEffect, defineEmits } from 'vue'
|
import { reactive, markRaw, getCurrentInstance, computed, watchEffect } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
Menu as IconMenu,
|
Menu as IconMenu,
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
<div class="top_bar_container">
|
<div class="top_bar_container">
|
||||||
<div class="bar_wrap">
|
<div class="bar_wrap">
|
||||||
<div class="mobile_menu_btn">
|
<div class="mobile_menu_btn">
|
||||||
<el-icon @click="handleCollapse"><Fold /></el-icon>
|
<el-icon @click="handleCollapse">
|
||||||
|
<Fold />
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<el-switch
|
<el-switch
|
||||||
@ -21,7 +23,12 @@
|
|||||||
关于 <span class="new_version">{{ isNew ? `(新版本可用)` : '' }}</span>
|
关于 <span class="new_version">{{ isNew ? `(新版本可用)` : '' }}</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<span class="username"><el-icon><User /></el-icon> {{ user }}</span>
|
<span class="username_wrap">
|
||||||
|
<el-icon>
|
||||||
|
<User />
|
||||||
|
</el-icon>
|
||||||
|
<span class="username">{{ user }}</span>
|
||||||
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item @click="handleLogout">
|
<el-dropdown-item @click="handleLogout">
|
||||||
@ -30,6 +37,87 @@
|
|||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
|
||||||
|
<el-popover placement="left" :width="320" trigger="hover">
|
||||||
|
<template #reference>
|
||||||
|
<img
|
||||||
|
class="plus_icon"
|
||||||
|
src="@/assets/plus.png"
|
||||||
|
alt="PLUS"
|
||||||
|
:style="{ filter: isPlusActive ? 'grayscale(0%)' : 'grayscale(100%)' }"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<div class="plus_content_wrap">
|
||||||
|
<!-- Plus 激活状态信息 -->
|
||||||
|
<div v-if="isPlusActive" class="plus_status">
|
||||||
|
<div class="status_header">
|
||||||
|
<el-icon>
|
||||||
|
<CircleCheckFilled />
|
||||||
|
</el-icon>
|
||||||
|
<span>Plus专属功能已激活</span>
|
||||||
|
</div>
|
||||||
|
<div class="status_info">
|
||||||
|
<div class="info_item">
|
||||||
|
<span class="label">到期时间:</span>
|
||||||
|
<span class="value holder">{{ plusInfo.expiryDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info_item">
|
||||||
|
<span class="label">授权IP数:</span>
|
||||||
|
<span class="value">{{ plusInfo.maxIPs }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info_item">
|
||||||
|
<span class="label">已授权IP数:</span>
|
||||||
|
<span class="value">{{ plusInfo.usedIPCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info_item ip_list">
|
||||||
|
<span class="label">已授权IP:</span>
|
||||||
|
<div class="ip_tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="ip in plusInfo.usedIPs"
|
||||||
|
:key="ip"
|
||||||
|
size="small"
|
||||||
|
class="ip_tag"
|
||||||
|
>
|
||||||
|
{{ ip }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plus_benefits" :class="{ active: isPlusActive }" @click="handlePlus">
|
||||||
|
<span v-if="!isPlusActive" class="support_btn" @click="handlePlusSupport">去支持</span>
|
||||||
|
<div class="benefits_header">
|
||||||
|
<el-icon>
|
||||||
|
<el-icon><StarFilled /></el-icon>
|
||||||
|
</el-icon>
|
||||||
|
<span>Plus功能介绍</span>
|
||||||
|
</div>
|
||||||
|
<div class="current_benefits">
|
||||||
|
<div v-for="plusFeature in plusFeatures" :key="plusFeature" class="benefit_item">
|
||||||
|
<el-icon>
|
||||||
|
<Star />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ plusFeature }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="coming_soon">
|
||||||
|
<div class="soon_header">开发中的PLUS功能</div>
|
||||||
|
<div class="current_benefits">
|
||||||
|
<div v-for="soonFeature in soonFeatures" :key="soonFeature" class="benefit_item">
|
||||||
|
<el-icon>
|
||||||
|
<Star />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ soonFeature }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@ -44,18 +132,36 @@
|
|||||||
<p>当前版本: {{ currentVersion }} <span v-show="!isNew">(最新)</span> </p>
|
<p>当前版本: {{ currentVersion }} <span v-show="!isNew">(最新)</span> </p>
|
||||||
<p v-if="checkVersionErr" class="conspicuous">Error:版本更新检测失败(版本检测API需要外网环境)</p>
|
<p v-if="checkVersionErr" class="conspicuous">Error:版本更新检测失败(版本检测API需要外网环境)</p>
|
||||||
<p v-if="isNew" class="conspicuous">
|
<p v-if="isNew" class="conspicuous">
|
||||||
新版本可用: {{ latestVersion }} -> <a class="link" href="https://github.com/chaos-zhu/easynode/releases" target="_blank">https://github.com/chaos-zhu/easynode/releases</a>
|
新版本可用: {{ latestVersion }} -> <a
|
||||||
|
class="link"
|
||||||
|
href="https://github.com/chaos-zhu/easynode/releases"
|
||||||
|
target="_blank"
|
||||||
|
>https://github.com/chaos-zhu/easynode/releases</a>
|
||||||
</p>
|
</p>
|
||||||
<p>更新日志:<a class="link" href="https://github.com/chaos-zhu/easynode/blob/main/CHANGELOG.md" target="_blank">https://github.com/chaos-zhu/easynode/blob/main/CHANGELOG.md</a></p>
|
|
||||||
<p>开源仓库: <a class="link" href="https://github.com/chaos-zhu/easynode" target="_blank">https://github.com/chaos-zhu/easynode</a></p>
|
|
||||||
<p>作者: <a class="link" href="https://github.com/chaos-zhu" target="_blank">chaoszhu</a></p>
|
|
||||||
<p>tg更新通知:<a class="link" href="https://t.me/easynode_notify" target="_blank">https://t.me/easynode_notify</a></p>
|
|
||||||
<p>
|
<p>
|
||||||
打赏: EasyNode开源且无任何收费,如果您认为此项目帮到了您, 您可以请我喝杯阔乐(记得留个备注)~
|
更新日志:<a
|
||||||
|
class="link"
|
||||||
|
href="https://github.com/chaos-zhu/easynode/blob/main/CHANGELOG.md"
|
||||||
|
target="_blank"
|
||||||
|
>https://github.com/chaos-zhu/easynode/blob/main/CHANGELOG.md</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="qrcode">
|
<p>
|
||||||
<img src="@/assets/wx.jpg" alt="">
|
tg更新通知:<a class="link" href="https://t.me/easynode_notify" target="_blank">https://t.me/easynode_notify</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p style="line-height: 2;letter-spacing: 1px;">
|
||||||
|
<strong style="color: #F56C6C;font-weight: 600;">PLUS说明:</strong><br>
|
||||||
|
<strong>EasyNode</strong>最初是一个简单的Web终端工具,随着用户群的不断扩大,功能需求也日益增长,为了实现大家的功能需求,我投入了大量的业余时间进行开发和维护。
|
||||||
|
一直在为爱发电,渐渐的也没了开发的动力。
|
||||||
|
<br>
|
||||||
|
为了项目的可持续发展,从<strong>3.0.0</strong>版本开始推出了<strong>PLUS</strong>版本,具体特性鼠标悬浮右上角PLUS图标查看,后续特性功能开发也会优先在<strong>PLUS</strong>版本中实现,但即使不升级到<strong>PLUS</strong>,也不会影响到<strong>EasyNode</strong>的基础功能使用【注意: 暂不支持纯内网用户激活PLUS功能】。
|
||||||
|
<br>
|
||||||
|
<span style="text-decoration: underline;">
|
||||||
|
为了感谢前期赞赏过的用户, 在<strong>PLUS</strong>功能正式发布前,所有进行过赞赏的用户,无论金额大小,均可联系作者TG: <a class="link" href="https://t.me/chaoszhu" target="_blank">@chaoszhu</a> 凭打赏记录获取永久<strong>PLUS</strong>授权码。
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="!isPlusActive" class="about_footer">
|
||||||
|
<el-button type="primary" @click="handlePlusSupport">去支持</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@ -76,7 +182,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, getCurrentInstance, computed } from 'vue'
|
import { ref, getCurrentInstance, computed } from 'vue'
|
||||||
import { User, Sunny, Moon, Fold } from '@element-plus/icons-vue'
|
import { User, Sunny, Moon, Fold, CircleCheckFilled, Star, StarFilled } from '@element-plus/icons-vue'
|
||||||
import packageJson from '../../package.json'
|
import packageJson from '../../package.json'
|
||||||
import MenuList from './menuList.vue'
|
import MenuList from './menuList.vue'
|
||||||
|
|
||||||
@ -88,9 +194,26 @@ const currentVersion = ref(`v${ packageJson.version }`)
|
|||||||
const latestVersion = ref(null)
|
const latestVersion = ref(null)
|
||||||
const menuCollapse = ref(false)
|
const menuCollapse = ref(false)
|
||||||
|
|
||||||
|
const plusFeatures = [
|
||||||
|
'跳板机功能,拯救被墙实例与龟速终端输入',
|
||||||
|
'本地socket断开自动重连,无需手动重新连接',
|
||||||
|
'批量修改实例配置(优化版)',
|
||||||
|
'脚本库批量导出导入',
|
||||||
|
'凭据管理支持解密带密码保护的密钥',
|
||||||
|
'提出的功能需求享有更高的开发优先级',
|
||||||
|
]
|
||||||
|
const soonFeatures = [
|
||||||
|
'终端脚本变量及终端脚本输入优化',
|
||||||
|
'终端分屏功能',
|
||||||
|
'系统操作日志审计',
|
||||||
|
]
|
||||||
|
|
||||||
const isNew = computed(() => latestVersion.value && latestVersion.value !== currentVersion.value)
|
const isNew = computed(() => latestVersion.value && latestVersion.value !== currentVersion.value)
|
||||||
const user = computed(() => $store.user)
|
const user = computed(() => $store.user)
|
||||||
const title = computed(() => $store.title)
|
const title = computed(() => $store.title)
|
||||||
|
const plusInfo = computed(() => $store.plusInfo)
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
const isDark = computed({
|
const isDark = computed({
|
||||||
get: () => $store.isDark,
|
get: () => $store.isDark,
|
||||||
set: (isDark) => {
|
set: (isDark) => {
|
||||||
@ -108,6 +231,10 @@ const handleLogout = () => {
|
|||||||
$router.push('/login')
|
$router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePlusSupport = () => {
|
||||||
|
window.open('https://en.221022.xyz/buy-plus', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
async function checkLatestVersion() {
|
async function checkLatestVersion() {
|
||||||
const timeout = 3000
|
const timeout = 3000
|
||||||
try {
|
try {
|
||||||
@ -151,47 +278,186 @@ checkLatestVersion()
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
.bar_wrap {
|
.bar_wrap {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark_switch {
|
.dark_switch {
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about_btn {
|
.about_btn {
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
.new_version {
|
.new_version {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.username_wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus_icon {
|
||||||
|
margin-left: 15px;
|
||||||
|
width: 35px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.about_content {
|
.about_content {
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 18px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 15px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
line-height: 35px;
|
line-height: 1.8;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.qrcode {
|
|
||||||
text-align: center;
|
.link {
|
||||||
img {
|
color: #409EFF;
|
||||||
width: 250px;
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.conspicuous {
|
.conspicuous {
|
||||||
color: red;
|
color: #F56C6C;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.about_footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.plus_content_wrap {
|
||||||
|
.plus_status {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
|
||||||
|
.status_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #67c23a;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_info {
|
||||||
|
.info_item {
|
||||||
|
display: flex;
|
||||||
|
margin: 5px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #909399;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holder {
|
||||||
|
color: #EED183;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ip_list {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.ip_tags {
|
||||||
|
margin-top: 5px;
|
||||||
|
|
||||||
|
.ip_tag {
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus_benefits {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.support_btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background-color: #409eff;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #66b1ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefits_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: #e6a23c;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current_benefits {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
.benefit_item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming_soon {
|
||||||
|
.soon_header {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ export default {
|
|||||||
},
|
},
|
||||||
// format: time OR date
|
// format: time OR date
|
||||||
formatTimestamp: (timestamp, format = 'time', afterSeparator = ':') => {
|
formatTimestamp: (timestamp, format = 'time', afterSeparator = ':') => {
|
||||||
if(typeof(timestamp) !== 'number') return '--'
|
if (typeof(timestamp) !== 'number') return '--'
|
||||||
let date = new Date(timestamp)
|
let date = new Date(timestamp)
|
||||||
let padZero = (num) => String(num).padStart(2, '0')
|
let padZero = (num) => String(num).padStart(2, '0')
|
||||||
let year = date.getFullYear()
|
let year = date.getFullYear()
|
||||||
|
@ -40,10 +40,13 @@ const useStore = defineStore({
|
|||||||
background: 'linear-gradient(-225deg, #CBBACC 0%, #2580B3 100%)',
|
background: 'linear-gradient(-225deg, #CBBACC 0%, #2580B3 100%)',
|
||||||
quickCopy: isHttps(),
|
quickCopy: isHttps(),
|
||||||
quickPaste: isHttps(),
|
quickPaste: isHttps(),
|
||||||
|
autoReconnect: true,
|
||||||
autoExecuteScript: false
|
autoExecuteScript: false
|
||||||
},
|
},
|
||||||
...(localStorage.getItem('terminalConfig') ? JSON.parse(localStorage.getItem('terminalConfig')) : {})
|
...(localStorage.getItem('terminalConfig') ? JSON.parse(localStorage.getItem('terminalConfig')) : {})
|
||||||
}
|
},
|
||||||
|
plusInfo: {},
|
||||||
|
isPlusActive: false
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async setJwtToken(token, isSession = true) {
|
async setJwtToken(token, isSession = true) {
|
||||||
@ -68,6 +71,7 @@ const useStore = defineStore({
|
|||||||
await this.getHostList()
|
await this.getHostList()
|
||||||
await this.getSSHList()
|
await this.getSSHList()
|
||||||
await this.getScriptList()
|
await this.getScriptList()
|
||||||
|
await this.getPlusInfo()
|
||||||
this.wsClientsStatus()
|
this.wsClientsStatus()
|
||||||
},
|
},
|
||||||
async getHostList() {
|
async getHostList() {
|
||||||
@ -96,6 +100,20 @@ const useStore = defineStore({
|
|||||||
const { data: localScriptList } = await $api.getLocalScriptList()
|
const { data: localScriptList } = await $api.getLocalScriptList()
|
||||||
this.$patch({ localScriptList })
|
this.$patch({ localScriptList })
|
||||||
},
|
},
|
||||||
|
async getPlusInfo() {
|
||||||
|
const { data: plusInfo } = await $api.getPlusInfo()
|
||||||
|
if (plusInfo?.expiryDate) {
|
||||||
|
const isPlusActive = new Date(plusInfo.expiryDate) > new Date()
|
||||||
|
this.$patch({ isPlusActive })
|
||||||
|
if (!isPlusActive) {
|
||||||
|
this.setTerminalSetting({ autoReconnect: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plusInfo.expiryDate = dayjs(plusInfo.expiryDate).format('YYYY-MM-DD')
|
||||||
|
plusInfo.expiryDate?.startsWith('9999') && (plusInfo.expiryDate = '永久授权')
|
||||||
|
}
|
||||||
|
this.$patch({ plusInfo })
|
||||||
|
},
|
||||||
setTerminalSetting(setTarget = {}) {
|
setTerminalSetting(setTarget = {}) {
|
||||||
let newConfig = { ...this.terminalConfig, ...setTarget }
|
let newConfig = { ...this.terminalConfig, ...setTarget }
|
||||||
localStorage.setItem('terminalConfig', JSON.stringify(newConfig))
|
localStorage.setItem('terminalConfig', JSON.stringify(newConfig))
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
// 终端连接状态
|
// 终端连接状态
|
||||||
export const terminalStatus = {
|
export const terminalStatus = {
|
||||||
CONNECTING: 'connecting',
|
CONNECTING: 'connecting',
|
||||||
RECONNECTING: 'reconnecting',
|
|
||||||
CONNECT_FAIL: 'connect_fail',
|
CONNECT_FAIL: 'connect_fail',
|
||||||
CONNECT_SUCCESS: 'connect_success'
|
CONNECT_SUCCESS: 'connect_success'
|
||||||
}
|
}
|
||||||
export const terminalStatusList = [
|
export const terminalStatusList = [
|
||||||
{ value: terminalStatus.CONNECTING, label: '连接中', color: '#FFA500' },
|
{ value: terminalStatus.CONNECTING, label: '连接中', color: '#FFA500' },
|
||||||
{ value: terminalStatus.RECONNECTING, label: '重连中', color: '#FFA500' },
|
|
||||||
{ value: terminalStatus.CONNECT_FAIL, label: '连接失败', color: '#DC3545' },
|
{ value: terminalStatus.CONNECT_FAIL, label: '连接失败', color: '#DC3545' },
|
||||||
{ value: terminalStatus.CONNECT_SUCCESS, label: '已连接', color: '#28A745' },
|
{ value: terminalStatus.CONNECT_SUCCESS, label: '已连接', color: '#28A745' },
|
||||||
]
|
]
|
||||||
|
@ -128,7 +128,7 @@ export const getSuffix = (name = '') => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const exportFile = (data, filename, mimeType = 'application/json') =>{
|
export const exportFile = (data, filename, mimeType = 'application/json') =>{
|
||||||
const blob = new Blob([JSON.stringify(data),], { type: mimeType })
|
const blob = new Blob([JSON.stringify(data, null, 2),], { type: mimeType })
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
|
@ -84,12 +84,41 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
<el-dialog
|
||||||
|
v-model="keyPasswordVisible"
|
||||||
|
title="输入密钥密码"
|
||||||
|
width="400px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form @submit.prevent>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input
|
||||||
|
v-model="keyPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密钥密码"
|
||||||
|
show-password
|
||||||
|
autocomplete="off"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleDecryptKey"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span>
|
||||||
|
<el-button @click="keyPasswordVisible = false">取消</el-button>
|
||||||
|
<PlusSupportTip>
|
||||||
|
<el-button type="primary" :disabled="!isPlusActive" @click="handleDecryptKey">确认</el-button>
|
||||||
|
</PlusSupportTip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
|
import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
|
||||||
import { randomStr, AESEncrypt, RSAEncrypt } from '@utils/index.js'
|
import { randomStr, AESEncrypt, RSAEncrypt } from '@utils/index.js'
|
||||||
|
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
|
||||||
|
|
||||||
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
|
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
|
||||||
|
|
||||||
@ -115,6 +144,7 @@ const updateFormRef = ref(null)
|
|||||||
const privateKeyRef = ref(null)
|
const privateKeyRef = ref(null)
|
||||||
|
|
||||||
let sshList = computed(() => $store.sshList)
|
let sshList = computed(() => $store.sshList)
|
||||||
|
let isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
let addCredentials = () => {
|
let addCredentials = () => {
|
||||||
sshForm.id = null
|
sshForm.id = null
|
||||||
@ -159,9 +189,9 @@ const removeSSH = ({ id, name }) => {
|
|||||||
type: 'warning'
|
type: 'warning'
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await $api.removeSSH(id) // 后台会同步删除关联此凭证的credential字段
|
await $api.removeSSH(id)
|
||||||
await $store.getSSHList()
|
await $store.getSSHList()
|
||||||
await $store.getHostList() // 刷新主机字段 isConfig
|
await $store.getHostList()
|
||||||
$message.success('success')
|
$message.success('success')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -170,16 +200,40 @@ const handleClickUploadBtn = () => {
|
|||||||
privateKeyRef.value.click()
|
privateKeyRef.value.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keyPasswordVisible = ref(false)
|
||||||
|
const keyPassword = ref('')
|
||||||
|
const tempPrivateKey = ref('')
|
||||||
|
|
||||||
const handleSelectPrivateKeyFile = (event) => {
|
const handleSelectPrivateKeyFile = (event) => {
|
||||||
let file = event.target.files[0]
|
let file = event.target.files[0]
|
||||||
let reader = new FileReader()
|
let reader = new FileReader()
|
||||||
reader.onload = (e) => {
|
reader.onload = async (e) => {
|
||||||
sshForm.privateKey = e.target.result
|
const content = e.target.result
|
||||||
|
// 检查是否是加密的私钥
|
||||||
|
if (content.includes('ENCRYPTED')) {
|
||||||
|
tempPrivateKey.value = content
|
||||||
|
keyPasswordVisible.value = true
|
||||||
|
} else {
|
||||||
|
sshForm.privateKey = content
|
||||||
|
}
|
||||||
privateKeyRef.value.value = ''
|
privateKeyRef.value.value = ''
|
||||||
}
|
}
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDecryptKey = async () => {
|
||||||
|
if (!keyPassword.value) return $message.error('请输入密钥密码')
|
||||||
|
const { data } = await $api.decryptPrivateKey({
|
||||||
|
privateKey: tempPrivateKey.value,
|
||||||
|
password: keyPassword.value
|
||||||
|
})
|
||||||
|
sshForm.privateKey = data
|
||||||
|
keyPasswordVisible.value = false
|
||||||
|
keyPassword.value = ''
|
||||||
|
tempPrivateKey.value = ''
|
||||||
|
$message.success('密钥解密成功')
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -53,6 +53,7 @@
|
|||||||
:trigger-on-focus="false"
|
:trigger-on-focus="false"
|
||||||
clearable
|
clearable
|
||||||
autofocus
|
autofocus
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item prop="jwtExpires" label="有效期">
|
<el-form-item prop="jwtExpires" label="有效期">
|
||||||
@ -89,7 +90,7 @@ const expireEnum = reactive({
|
|||||||
CURRENT_DAY: 'current_day',
|
CURRENT_DAY: 'current_day',
|
||||||
THREE_DAY: 'three_day'
|
THREE_DAY: 'three_day'
|
||||||
})
|
})
|
||||||
const expireTime = ref(expireEnum.ONE_SESSION)
|
const expireTime = ref(expireEnum.CURRENT_DAY)
|
||||||
const loginFormRefs = ref(null)
|
const loginFormRefs = ref(null)
|
||||||
const notKey = ref(false)
|
const notKey = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
226
web/src/views/scripts/components/import-script.vue
Normal file
226
web/src/views/scripts/components/import-script.vue
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="600px"
|
||||||
|
top="225px"
|
||||||
|
modal-class="import_form_dialog"
|
||||||
|
append-to-body
|
||||||
|
title="导入脚本配置"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<h2>选择要导入的文件类型</h2>
|
||||||
|
<ul class="type_list">
|
||||||
|
<li @click="handleFromJson">
|
||||||
|
<svg-icon name="icon-json" class="icon" />
|
||||||
|
<span class="from">JSON</span>
|
||||||
|
<input
|
||||||
|
ref="jsonInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
multiple
|
||||||
|
name="jsonInput"
|
||||||
|
style="display: none;"
|
||||||
|
@change="handleJsonFile"
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li @click="manualInputVisible = true">
|
||||||
|
<svg-icon name="icon-bianji1" class="icon" />
|
||||||
|
<span class="from">手动输入</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="manualInputVisible"
|
||||||
|
width="600px"
|
||||||
|
top="150px"
|
||||||
|
title="手动输入"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="manualInput"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 15 }"
|
||||||
|
placeholder="请输入脚本内容,每行一条脚本"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<div class="manual-input-footer">
|
||||||
|
<el-button @click="manualInputVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleManualImport">导入</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
|
const { proxy: { $api, $message, $store } } = getCurrentInstance()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show', 'update-list',])
|
||||||
|
|
||||||
|
const jsonInputRef = ref(null)
|
||||||
|
const manualInputVisible = ref(false)
|
||||||
|
const manualInput = ref('')
|
||||||
|
|
||||||
|
let visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (newVal) => emit('update:show', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
let scriptList = computed(() => $store.scriptList)
|
||||||
|
|
||||||
|
function handleFromJson() {
|
||||||
|
jsonInputRef.value.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJsonFile = (event) => {
|
||||||
|
let files = event.target.files
|
||||||
|
let jsonFiles = Array.from(files).filter(file => file.name.endsWith('.json'))
|
||||||
|
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
|
||||||
|
|
||||||
|
let readerPromises = jsonFiles.map(file => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
let jsonContent = JSON.parse(e.target.result)
|
||||||
|
resolve(jsonContent)
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to parse JSON file: ${ file.name }`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error(`Failed to read file: ${ file.name }`))
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Promise.all(readerPromises)
|
||||||
|
.then(async jsonContents => {
|
||||||
|
let formatJson = jsonContents.flat(Infinity)
|
||||||
|
let existCommand = scriptList.value.map(item => item.command)
|
||||||
|
let existId = scriptList.value.map(item => item.id)
|
||||||
|
formatJson = formatJson.filter(({ _id, command }) => {
|
||||||
|
return !existCommand.includes(command) && !existId.includes(_id)
|
||||||
|
})
|
||||||
|
if (formatJson.length === 0) return $message.warning('导入的脚本已存在')
|
||||||
|
try {
|
||||||
|
let { data: { len } } = await $api.importScript({ scripts: formatJson })
|
||||||
|
$message({ type: 'success', center: true, message: `成功导入脚本: ${ len }条` })
|
||||||
|
emit('update-list')
|
||||||
|
visible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
$message.error('导入失败: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
$message.error('导入失败: ' + error.message)
|
||||||
|
console.error('导入失败: ', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
event.target.value = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualImport = async () => {
|
||||||
|
if (!manualInput.value.trim()) {
|
||||||
|
return $message.warning('请输入脚本内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let scripts = manualInput.value.split('\n')
|
||||||
|
scripts = [...new Set(scripts),]
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map((command) => ({ command: command.trim() }))
|
||||||
|
if (scripts.length === 0) {
|
||||||
|
return $message.warning('未检测到有效的脚本内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
let existCommand = scriptList.value.map(item => item.command)
|
||||||
|
let filterScripts = scripts.filter(({ command }) => {
|
||||||
|
return !existCommand.includes(command)
|
||||||
|
})
|
||||||
|
let filterScriptsLen = filterScripts.length
|
||||||
|
if (filterScriptsLen !== 0 && filterScriptsLen < scripts.length) $message.warning('已过滤重复的脚本')
|
||||||
|
if (filterScriptsLen === 0) return $message.warning('导入的脚本已存在')
|
||||||
|
filterScripts = filterScripts.map((item, index) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
name: `${ item.command.slice(0, 15) || `脚本${ index + 1 }` }`,
|
||||||
|
index: scriptList.value.length + index + 1,
|
||||||
|
description: '手动输入'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let { data: { len } } = await $api.importScript({ scripts: filterScripts })
|
||||||
|
$message({ type: 'success', center: true, message: `成功导入脚本: ${ len }条` })
|
||||||
|
emit('update-list')
|
||||||
|
manualInputVisible.value = false
|
||||||
|
visible.value = false
|
||||||
|
manualInput.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
$message.error('导入失败: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.import_form_dialog {
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin: 15px 0 25px 0;
|
||||||
|
}
|
||||||
|
.type_list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
li {
|
||||||
|
margin: 0 25px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-menu-active-color);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.from {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
.type {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-input-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,9 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="scripts_container">
|
<div class="scripts_container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<el-button type="primary" @click="addScript">添加脚本</el-button>
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索名称、描述或指令内容"
|
||||||
|
class="search_input"
|
||||||
|
clearable
|
||||||
|
@input="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button
|
||||||
|
v-show="selectScripts.length"
|
||||||
|
type="danger"
|
||||||
|
class="batch_remove_btn"
|
||||||
|
@click="handleBatchRemove"
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" class="add_script_btn" @click="addScript">添加脚本</el-button>
|
||||||
|
<PlusSupportTip>
|
||||||
|
<el-dropdown trigger="click" :disabled="!isPlusActive">
|
||||||
|
<el-button type="primary" class="group_action_btn" :disabled="!isPlusActive">
|
||||||
|
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item @click="importVisible = true">导入脚本</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="handleExport">导出脚本</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</PlusSupportTip>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="scriptList">
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="paginatedFilteredList"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
type="selection"
|
||||||
|
width="55"
|
||||||
|
:selectable="(row) => {
|
||||||
|
return row.index !== '--' && row.index !== '-' && row.index !== undefined && row.index !== null
|
||||||
|
}"
|
||||||
|
/>
|
||||||
<el-table-column prop="index" label="序号" width="100px" />
|
<el-table-column prop="index" label="序号" width="100px" />
|
||||||
<el-table-column prop="name" label="名称" />
|
<el-table-column prop="name" label="名称" />
|
||||||
<el-table-column prop="description" label="描述" />
|
<el-table-column prop="description" label="描述" />
|
||||||
@ -18,6 +61,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-container">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
:total="scriptList.length"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="formVisible"
|
v-model="formVisible"
|
||||||
width="600px"
|
width="600px"
|
||||||
@ -78,17 +134,44 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<ImportScript
|
||||||
|
v-model:show="importVisible"
|
||||||
|
@update-list="() => $store.getScriptList()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
|
import { ref, reactive, computed, nextTick, getCurrentInstance, h } from 'vue'
|
||||||
|
import ImportScript from './components/import-script.vue'
|
||||||
|
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
|
||||||
|
import { ArrowDown, Search } from '@element-plus/icons-vue'
|
||||||
|
import { exportFile } from '@/utils'
|
||||||
|
|
||||||
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
|
const { proxy: { $api, $message, $messageBox, $store, $tools } } = getCurrentInstance()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const formVisible = ref(false)
|
const formVisible = ref(false)
|
||||||
let isModify = ref(false)
|
let isModify = ref(false)
|
||||||
|
const selectScripts = ref([])
|
||||||
|
const handleSelectionChange = (val) => {
|
||||||
|
selectScripts.value = val
|
||||||
|
}
|
||||||
|
const handleBatchRemove = () => {
|
||||||
|
if (!selectScripts.value.length) return $message.warning('请选择要批量删除的脚本')
|
||||||
|
let ids = selectScripts.value.map(item => item.id)
|
||||||
|
let names = selectScripts.value.map(item => item.name)
|
||||||
|
$messageBox.confirm(() => h('p', { style: 'line-height: 18px;' }, `确认删除\n${ names.join(', ') }吗?`), 'Warning', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
await $api.batchRemoveScript({ ids })
|
||||||
|
await $store.getScriptList()
|
||||||
|
$message.success('success')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let formData = reactive({
|
let formData = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@ -108,7 +191,8 @@ const rules = computed(() => {
|
|||||||
|
|
||||||
const updateFormRef = ref(null)
|
const updateFormRef = ref(null)
|
||||||
|
|
||||||
let scriptList = computed(() => $store.scriptList)
|
const scriptList = computed(() => $store.scriptList)
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
let addScript = () => {
|
let addScript = () => {
|
||||||
formData.id = null
|
formData.id = null
|
||||||
@ -154,6 +238,49 @@ const handleRemove = ({ id, name }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importVisible = ref(false)
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!scriptList.value.length) return $message.warning('暂无可导出的脚本')
|
||||||
|
const fileName = `easynode-scripts-${ $tools.formatTimestamp(Date.now(), 'time', '.') }.json`
|
||||||
|
exportFile(scriptList.value, fileName, 'application/json')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
const filteredScriptList = computed(() => {
|
||||||
|
if (!searchKeyword.value) return scriptList.value
|
||||||
|
|
||||||
|
const keyword = searchKeyword.value.toLowerCase()
|
||||||
|
return scriptList.value.filter(item =>
|
||||||
|
(item.name && item.name.toLowerCase().includes(keyword)) ||
|
||||||
|
(item.description && item.description.toLowerCase().includes(keyword)) ||
|
||||||
|
(item.command && item.command.toLowerCase().includes(keyword))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedFilteredList = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
const end = start + pageSize.value
|
||||||
|
return filteredScriptList.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSizeChange = (val) => {
|
||||||
|
pageSize.value = val
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (val) => {
|
||||||
|
currentPage.value = val
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@ -164,6 +291,13 @@ const handleRemove = ({ id, name }) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
|
.add_script_btn {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
.search_input {
|
||||||
|
width: 300px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,4 +309,10 @@ const handleRemove = ({ id, name }) => {
|
|||||||
color: #87cf63;
|
color: #87cf63;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -30,7 +30,6 @@
|
|||||||
label-width="100px"
|
label-width="100px"
|
||||||
:show-message="false"
|
:show-message="false"
|
||||||
>
|
>
|
||||||
<transition-group name="list" mode="out-in" tag="div">
|
|
||||||
<el-form-item key="group" label="分组" prop="group">
|
<el-form-item key="group" label="分组" prop="group">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="hostForm.group"
|
v-model="hostForm.group"
|
||||||
@ -114,14 +113,12 @@
|
|||||||
<el-button type="primary" size="small" @click="handleClickUploadBtn">
|
<el-button type="primary" size="small" @click="handleClickUploadBtn">
|
||||||
本地私钥...
|
本地私钥...
|
||||||
</el-button>
|
</el-button>
|
||||||
<!-- <el-button type="primary" size="small" @click="handleClickUploadBtn">
|
|
||||||
从凭据导入...
|
|
||||||
</el-button> -->
|
|
||||||
<input
|
<input
|
||||||
ref="privateKeyRef"
|
ref="privateKeyRef"
|
||||||
type="file"
|
type="file"
|
||||||
name="privateKey"
|
name="privateKey"
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
|
autocomplete="off"
|
||||||
@change="handleSelectPrivateKeyFile"
|
@change="handleSelectPrivateKeyFile"
|
||||||
>
|
>
|
||||||
<el-input
|
<el-input
|
||||||
@ -144,7 +141,7 @@
|
|||||||
v-model.trim="hostForm.password"
|
v-model.trim="hostForm.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
autocomplete="off"
|
autocomplete="new-password"
|
||||||
clearable
|
clearable
|
||||||
show-password
|
show-password
|
||||||
/>
|
/>
|
||||||
@ -155,9 +152,9 @@
|
|||||||
prop="credential"
|
prop="credential"
|
||||||
label="凭据"
|
label="凭据"
|
||||||
>
|
>
|
||||||
<el-select v-model="hostForm.credential" class="credential_select" placeholder="">
|
<el-select v-model="hostForm.credential" placeholder="">
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<div class="empty_credential">
|
<div class="empty_text">
|
||||||
<span>无凭据数据,</span>
|
<span>无凭据数据,</span>
|
||||||
<el-button type="primary" link @click="toCredentials">
|
<el-button type="primary" link @click="toCredentials">
|
||||||
去添加
|
去添加
|
||||||
@ -170,7 +167,7 @@
|
|||||||
:label="item.name"
|
:label="item.name"
|
||||||
:value="item.id"
|
:value="item.id"
|
||||||
>
|
>
|
||||||
<div class="auth_type_wrap">
|
<div class="select_warp">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="auth_type_text">
|
<span class="auth_type_text">
|
||||||
{{ item.authType === 'privateKey' ? '密钥' : '密码' }}
|
{{ item.authType === 'privateKey' ? '密钥' : '密码' }}
|
||||||
@ -179,7 +176,37 @@
|
|||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item key="command" prop="command" label="执行指令">
|
<el-form-item
|
||||||
|
key="jumpHosts"
|
||||||
|
prop="jumpHosts"
|
||||||
|
label="跳板机"
|
||||||
|
>
|
||||||
|
<PlusSupportTip>
|
||||||
|
<el-select
|
||||||
|
v-model="hostForm.jumpHosts"
|
||||||
|
placeholder="支持多选,跳板机连接顺序从前到后"
|
||||||
|
multiple
|
||||||
|
:disabled="!isPlusActive"
|
||||||
|
>
|
||||||
|
<template #empty>
|
||||||
|
<div class="empty_text">
|
||||||
|
<span>无可用跳板机器</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-option
|
||||||
|
v-for="item in confHostList"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
>
|
||||||
|
<div class="select_wrap">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</PlusSupportTip>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item key="command" prop="command" label="登录指令">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="hostForm.command"
|
v-model="hostForm.command"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@ -250,12 +277,13 @@
|
|||||||
placeholder="简单记录实例用途"
|
placeholder="简单记录实例用途"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</transition-group>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="visible = false">关闭</el-button>
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
<el-button type="primary" @click="handleSave">确认</el-button>
|
<PlusSupportTip>
|
||||||
|
<el-button type="primary" :disabled="!isPlusActive" @click="handleSave">确认</el-button>
|
||||||
|
</PlusSupportTip>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -263,6 +291,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
|
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
|
||||||
|
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
|
||||||
import { RSAEncrypt, AESEncrypt, randomStr } from '@utils/index.js'
|
import { RSAEncrypt, AESEncrypt, randomStr } from '@utils/index.js'
|
||||||
|
|
||||||
const { proxy: { $api, $router, $message, $store } } = getCurrentInstance()
|
const { proxy: { $api, $router, $message, $store } } = getCurrentInstance()
|
||||||
@ -296,7 +325,7 @@ const formField = {
|
|||||||
host: '',
|
host: '',
|
||||||
port: 22,
|
port: 22,
|
||||||
username: 'root',
|
username: 'root',
|
||||||
authType: 'privateKey',
|
authType: 'privateKey', // privateKey, password, credential
|
||||||
password: '',
|
password: '',
|
||||||
privateKey: '',
|
privateKey: '',
|
||||||
credential: '', // credentials -> _id
|
credential: '', // credentials -> _id
|
||||||
@ -306,12 +335,12 @@ const formField = {
|
|||||||
expiredNotify: false,
|
expiredNotify: false,
|
||||||
consoleUrl: '',
|
consoleUrl: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
command: ''
|
command: '',
|
||||||
|
jumpHosts: []
|
||||||
}
|
}
|
||||||
|
|
||||||
let hostForm = ref({ ...formField })
|
let hostForm = ref({ ...formField })
|
||||||
let privateKeyRef = ref(null)
|
let privateKeyRef = ref(null)
|
||||||
let oldHost = ref('')
|
|
||||||
let formRef = ref(null)
|
let formRef = ref(null)
|
||||||
|
|
||||||
let isBatchModify = computed(() => props.isBatchModify)
|
let isBatchModify = computed(() => props.isBatchModify)
|
||||||
@ -323,7 +352,8 @@ const rules = computed(() => {
|
|||||||
name: { required: !isBatchModify.value, message: '输入实例别名', trigger: 'change' },
|
name: { required: !isBatchModify.value, message: '输入实例别名', trigger: 'change' },
|
||||||
host: { required: !isBatchModify.value, message: '输入IP/域名', trigger: 'change' },
|
host: { required: !isBatchModify.value, message: '输入IP/域名', trigger: 'change' },
|
||||||
port: { required: !isBatchModify.value, type: 'number', message: '输入ssh端口', trigger: 'change' },
|
port: { required: !isBatchModify.value, type: 'number', message: '输入ssh端口', trigger: 'change' },
|
||||||
clientPort: { required: false, type: 'number', message: '输入ssh端口', trigger: 'change' },
|
clientPort: { required: false, type: 'number' },
|
||||||
|
jumpHosts: { required: false, type: 'array' },
|
||||||
index: { required: !isBatchModify.value, type: 'number', message: '输入数字', trigger: 'change' },
|
index: { required: !isBatchModify.value, type: 'number', message: '输入数字', trigger: 'change' },
|
||||||
// password: [{ required: hostForm.authType === 'password', trigger: 'change' },],
|
// password: [{ required: hostForm.authType === 'password', trigger: 'change' },],
|
||||||
// privateKey: [{ required: hostForm.authType === 'privateKey', trigger: 'change' },],
|
// privateKey: [{ required: hostForm.authType === 'privateKey', trigger: 'change' },],
|
||||||
@ -333,6 +363,7 @@ const rules = computed(() => {
|
|||||||
remark: { required: false }
|
remark: { required: false }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.show,
|
get: () => props.show,
|
||||||
@ -345,29 +376,38 @@ const title = computed(() => {
|
|||||||
|
|
||||||
let groupList = computed(() => $store.groupList)
|
let groupList = computed(() => $store.groupList)
|
||||||
let sshList = computed(() => $store.sshList)
|
let sshList = computed(() => $store.sshList)
|
||||||
|
let hostList = computed(() => $store.hostList)
|
||||||
|
let confHostList = computed(() => {
|
||||||
|
return hostList.value?.filter(item => item.isConfig)
|
||||||
|
})
|
||||||
|
|
||||||
const setDefaultData = () => {
|
const setDefaultData = () => {
|
||||||
if (!defaultData.value) return
|
if (!defaultData.value) return
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
let { host, monitorData, ...rest } = defaultData.value
|
let { id, ...rest } = defaultData.value
|
||||||
oldHost.value = host
|
for (let [key,] of Object.entries(hostForm.value)) {
|
||||||
Object.assign(hostForm.value, { host, ...rest })
|
if (rest[key] !== undefined) hostForm.value[key] = rest[key]
|
||||||
|
}
|
||||||
|
hostForm.value.id = id
|
||||||
}
|
}
|
||||||
|
|
||||||
const setBatchDefaultData = () => {
|
const setBatchDefaultData = () => {
|
||||||
if (!isBatchModify.value) return
|
if (!isBatchModify.value) return
|
||||||
Object.assign(hostForm.value, { ...formField }, { group: '', port: '', username: '', authType: '', clientPort: '' })
|
Object.assign(hostForm.value, { ...formField }, { group: '', port: '', username: '', authType: '', clientPort: '', jumpHosts: [] })
|
||||||
}
|
}
|
||||||
const handleOpen = async () => {
|
const handleOpen = async () => {
|
||||||
setDefaultData()
|
if (isBatchModify.value) {
|
||||||
setBatchDefaultData()
|
setBatchDefaultData()
|
||||||
|
} else {
|
||||||
|
setDefaultData()
|
||||||
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
formRef.value.clearValidate()
|
formRef.value.clearValidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClosed = async () => {
|
const handleClosed = async () => {
|
||||||
emit('closed')
|
emit('closed')
|
||||||
Object.assign(hostForm.value, { ...formField })
|
hostForm.value = { ...formField }
|
||||||
await nextTick()
|
await nextTick()
|
||||||
formRef.value.resetFields()
|
formRef.value.resetFields()
|
||||||
}
|
}
|
||||||
@ -414,30 +454,39 @@ const handleSave = () => {
|
|||||||
let formData = { ...hostForm.value }
|
let formData = { ...hostForm.value }
|
||||||
if (isBatchModify.value) {
|
if (isBatchModify.value) {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
let updateFileData = Object.fromEntries(Object.entries(formData).filter(([key, value]) => Boolean(value))) // 剔除掉未更改的值
|
let updateFieldData = Object.fromEntries(Object.entries(formData).filter(([key, value]) => {
|
||||||
if (Object.keys(updateFileData).length === 0) return $message.warning('没有任何修改')
|
if (Array.isArray(value)) return value.length > 0
|
||||||
console.log(updateFileData)
|
return Boolean(value)
|
||||||
let newHosts = batchHosts.value
|
})) // 剔除掉未更改的值
|
||||||
.map(item => ({ ...item, ...updateFileData }))
|
let { authType = '' } = updateFieldData
|
||||||
.map(item => {
|
if (authType && !updateFieldData[authType]) {
|
||||||
const { authType } = item
|
delete updateFieldData.authType
|
||||||
|
delete updateFieldData.privateKey
|
||||||
|
delete updateFieldData.password
|
||||||
|
delete updateFieldData.credential
|
||||||
|
}
|
||||||
|
if (Object.keys(updateFieldData).length === 0) return $message.warning('没有任何修改')
|
||||||
|
console.log(updateFieldData)
|
||||||
|
if (updateFieldData.authType) {
|
||||||
let tempKey = randomStr(16)
|
let tempKey = randomStr(16)
|
||||||
if (item[authType]) item[authType] = AESEncrypt(item[authType], tempKey)
|
updateFieldData[authType] = AESEncrypt(updateFieldData[authType], tempKey)
|
||||||
item.tempKey = RSAEncrypt(tempKey)
|
updateFieldData.tempKey = RSAEncrypt(tempKey)
|
||||||
return item
|
}
|
||||||
})
|
let updateIds = batchHosts.value.map(item => item.id)
|
||||||
let { msg } = await $api.updateHost({ hosts: newHosts })
|
let { msg } = await $api.batchUpdateHost({ updateIds, updateFieldData })
|
||||||
$message({ type: 'success', center: true, message: msg })
|
$message({ type: 'success', center: true, message: msg })
|
||||||
} else {
|
} else {
|
||||||
let tempKey = randomStr(16)
|
|
||||||
let { authType } = formData
|
let { authType } = formData
|
||||||
if (formData[authType]) formData[authType] = AESEncrypt(formData[authType], tempKey)
|
if (formData[authType]) {
|
||||||
|
let tempKey = randomStr(16)
|
||||||
|
formData[authType] = AESEncrypt(formData[authType], tempKey)
|
||||||
formData.tempKey = RSAEncrypt(tempKey)
|
formData.tempKey = RSAEncrypt(tempKey)
|
||||||
|
}
|
||||||
if (defaultData.value) {
|
if (defaultData.value) {
|
||||||
let { msg } = await $api.updateHost(Object.assign({}, formData, { oldHost: oldHost.value }))
|
let { msg } = await $api.updateHost({ ...formData })
|
||||||
$message({ type: 'success', center: true, message: msg })
|
$message({ type: 'success', center: true, message: msg })
|
||||||
} else {
|
} else {
|
||||||
let { msg } = await $api.addHost(formData)
|
let { msg } = await $api.addHost({ ...formData })
|
||||||
$message({ type: 'success', center: true, message: msg })
|
$message({ type: 'success', center: true, message: msg })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -472,13 +521,13 @@ const handleSave = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty_credential {
|
.empty_text {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth_type_wrap {
|
.select_warp {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -133,7 +133,7 @@ const handleCsvFile = (event) => {
|
|||||||
|
|
||||||
const handleJsonFile = (event) => {
|
const handleJsonFile = (event) => {
|
||||||
let files = event.target.files
|
let files = event.target.files
|
||||||
let jsonFiles = Array.from(files).filter(file => file.type === 'application/json')
|
let jsonFiles = Array.from(files).filter(file => file.name.endsWith('.json'))
|
||||||
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
|
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
|
||||||
|
|
||||||
let readerPromises = jsonFiles.map(file => {
|
let readerPromises = jsonFiles.map(file => {
|
||||||
|
@ -3,10 +3,6 @@
|
|||||||
<div class="server_group_header">
|
<div class="server_group_header">
|
||||||
<!-- <el-button v-show="selectHosts.length" type="primary" @click="hostFormVisible = true">批量操作</el-button> -->
|
<!-- <el-button v-show="selectHosts.length" type="primary" @click="hostFormVisible = true">批量操作</el-button> -->
|
||||||
<el-button type="primary" class="add_host_btn" @click="hostFormVisible = true">添加实例</el-button>
|
<el-button type="primary" class="add_host_btn" @click="hostFormVisible = true">添加实例</el-button>
|
||||||
<!-- <el-button type="primary" @click="handleHiddenIP">
|
|
||||||
{{ hiddenIp ? '显示IP' : '隐藏IP' }}
|
|
||||||
</el-button> -->
|
|
||||||
<!-- <el-button type="primary" @click="importVisible = true">导入实例</el-button> -->
|
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<el-button type="primary" class="group_action_btn">
|
<el-button type="primary" class="group_action_btn">
|
||||||
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
@ -44,17 +40,9 @@
|
|||||||
<el-collapse-item v-for="(hosts, groupName) in groupHostList" :key="groupName" :name="groupName">
|
<el-collapse-item v-for="(hosts, groupName) in groupHostList" :key="groupName" :name="groupName">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="group_title">
|
<div class="group_title">
|
||||||
{{ groupName }}
|
{{ `${groupName}`+`${hosts.length ? `(${hosts.length})` : ''}` }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- <HostCard
|
|
||||||
v-for="(item, index) in hosts"
|
|
||||||
:key="index"
|
|
||||||
:host-info="item"
|
|
||||||
:hidden-ip="hiddenIp"
|
|
||||||
@update-host="handleUpdateHost"
|
|
||||||
@update-list="handleUpdateList"
|
|
||||||
/> -->
|
|
||||||
<HostTable
|
<HostTable
|
||||||
ref="hostTableRefs"
|
ref="hostTableRefs"
|
||||||
:hosts="hosts"
|
:hosts="hosts"
|
||||||
@ -90,17 +78,15 @@ import { exportFile } from '@/utils'
|
|||||||
|
|
||||||
const { proxy: { $api, $store, $router, $message, $messageBox, $tools } } = getCurrentInstance()
|
const { proxy: { $api, $store, $router, $message, $messageBox, $tools } } = getCurrentInstance()
|
||||||
|
|
||||||
let updateHostData = ref(null)
|
const updateHostData = ref(null)
|
||||||
let hostFormVisible = ref(false)
|
const hostFormVisible = ref(false)
|
||||||
let importVisible = ref(false)
|
const importVisible = ref(false)
|
||||||
let selectHosts = ref([])
|
const selectHosts = ref([])
|
||||||
let isBatchModify = ref(false)
|
const isBatchModify = ref(false)
|
||||||
const hostTableRefs = ref([])
|
const hostTableRefs = ref([])
|
||||||
|
const activeGroup = ref([])
|
||||||
|
|
||||||
let hiddenIp = ref(Number(localStorage.getItem('hiddenIp') || 0))
|
const handleUpdateList = async () => {
|
||||||
let activeGroup = ref([])
|
|
||||||
|
|
||||||
let handleUpdateList = async () => {
|
|
||||||
try {
|
try {
|
||||||
await $store.getHostList()
|
await $store.getHostList()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -178,11 +164,6 @@ let handleBatchExport = () => {
|
|||||||
hostTableRefs.value.forEach(item => item.clearSelection())
|
hostTableRefs.value.forEach(item => item.clearSelection())
|
||||||
}
|
}
|
||||||
|
|
||||||
let handleHiddenIP = () => {
|
|
||||||
hiddenIp.value = hiddenIp.value ? 0 : 1
|
|
||||||
localStorage.setItem('hiddenIp', String(hiddenIp.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
let hostList = computed(() => $store.hostList)
|
let hostList = computed(() => $store.hostList)
|
||||||
|
|
||||||
let groupHostList = computed(() => {
|
let groupHostList = computed(() => {
|
||||||
|
@ -15,9 +15,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<span style="margin-right: 10px;">{{ host }}</span>
|
<span style="margin-right: 10px;">{{ host }}</span>
|
||||||
<template v-if="pingMs">
|
<template v-if="pingMs">
|
||||||
|
<el-tooltip effect="dark" content="该值为EasyNode服务端主机到目标主机的ping值" placement="bottom">
|
||||||
<span class="host-ping" :style="{backgroundColor: handlePingColor(pingMs)}">{{ pingMs }}ms</span>
|
<span class="host-ping" :style="{backgroundColor: handlePingColor(pingMs)}">{{ pingMs }}ms</span>
|
||||||
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<el-tag size="small" style="cursor: pointer;margin-left: 15px;" @click="handleCopy">复制</el-tag>
|
<el-tag size="small" style="cursor: pointer;margin-left: 10px;" @click="handleCopy">复制</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item>
|
<el-descriptions-item>
|
||||||
<template #label>
|
<template #label>
|
||||||
|
@ -66,6 +66,22 @@
|
|||||||
label-width="100px"
|
label-width="100px"
|
||||||
:show-message="false"
|
:show-message="false"
|
||||||
>
|
>
|
||||||
|
<el-form-item label="自动重连" prop="autoReconnect">
|
||||||
|
<PlusSupportTip>
|
||||||
|
<span>
|
||||||
|
<el-switch
|
||||||
|
v-model="autoReconnect"
|
||||||
|
class="swtich"
|
||||||
|
inline-prompt
|
||||||
|
:disabled="!isPlusActive"
|
||||||
|
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
|
||||||
|
active-text="开启"
|
||||||
|
inactive-text="关闭"
|
||||||
|
/>
|
||||||
|
<span class="plus_support_tip_text">(Plus专属功能)</span>
|
||||||
|
</span>
|
||||||
|
</PlusSupportTip>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="选中复制" prop="quickCopy">
|
<el-form-item label="选中复制" prop="quickCopy">
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
effect="dark"
|
effect="dark"
|
||||||
@ -129,6 +145,7 @@
|
|||||||
import { computed, getCurrentInstance } from 'vue'
|
import { computed, getCurrentInstance } from 'vue'
|
||||||
import themeList from 'xterm-theme'
|
import themeList from 'xterm-theme'
|
||||||
import useMobileWidth from '@/composables/useMobileWidth'
|
import useMobileWidth from '@/composables/useMobileWidth'
|
||||||
|
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
|
||||||
const { proxy: { $store } } = getCurrentInstance()
|
const { proxy: { $store } } = getCurrentInstance()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -162,6 +179,10 @@ const quickCopy = computed({
|
|||||||
get: () => $store.terminalConfig.quickCopy,
|
get: () => $store.terminalConfig.quickCopy,
|
||||||
set: (newVal) => $store.setTerminalSetting({ quickCopy: newVal })
|
set: (newVal) => $store.setTerminalSetting({ quickCopy: newVal })
|
||||||
})
|
})
|
||||||
|
const autoReconnect = computed({
|
||||||
|
get: () => $store.terminalConfig.autoReconnect,
|
||||||
|
set: (newVal) => $store.setTerminalSetting({ autoReconnect: newVal })
|
||||||
|
})
|
||||||
const quickPaste = computed({
|
const quickPaste = computed({
|
||||||
get: () => $store.terminalConfig.quickPaste,
|
get: () => $store.terminalConfig.quickPaste,
|
||||||
set: (newVal) => $store.setTerminalSetting({ quickPaste: newVal })
|
set: (newVal) => $store.setTerminalSetting({ quickPaste: newVal })
|
||||||
@ -170,6 +191,7 @@ const autoExecuteScript = computed({
|
|||||||
get: () => $store.terminalConfig.autoExecuteScript,
|
get: () => $store.terminalConfig.autoExecuteScript,
|
||||||
set: (newVal) => $store.setTerminalSetting({ autoExecuteScript: newVal })
|
set: (newVal) => $store.setTerminalSetting({ autoExecuteScript: newVal })
|
||||||
})
|
})
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
const changeBackground = (item) => {
|
const changeBackground = (item) => {
|
||||||
background.value = item || ''
|
background.value = item || ''
|
||||||
@ -225,5 +247,8 @@ const changeBackground = (item) => {
|
|||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.plus_support_tip_text {
|
||||||
|
margin-left: 5px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -24,7 +24,7 @@ import socketIo from 'socket.io-client'
|
|||||||
import themeList from 'xterm-theme'
|
import themeList from 'xterm-theme'
|
||||||
import { terminalStatus } from '@/utils/enum'
|
import { terminalStatus } from '@/utils/enum'
|
||||||
|
|
||||||
const { CONNECTING, RECONNECTING, CONNECT_SUCCESS, CONNECT_FAIL } = terminalStatus
|
const { CONNECTING, CONNECT_SUCCESS, CONNECT_FAIL } = terminalStatus
|
||||||
|
|
||||||
const { io } = socketIo
|
const { io } = socketIo
|
||||||
const { proxy: { $api, $store, $serviceURI, $notification, $router, $message } } = getCurrentInstance()
|
const { proxy: { $api, $store, $serviceURI, $notification, $router, $message } } = getCurrentInstance()
|
||||||
@ -49,12 +49,11 @@ const emit = defineEmits(['inputCommand', 'cdCommand', 'ping-data', 'reset-long-
|
|||||||
const socket = ref(null)
|
const socket = ref(null)
|
||||||
// const commandHistoryList = ref([])
|
// const commandHistoryList = ref([])
|
||||||
const term = ref(null)
|
const term = ref(null)
|
||||||
const command = ref('')
|
const initCommand = ref('')
|
||||||
const timer = ref(null)
|
const timer = ref(null)
|
||||||
const pingTimer = ref(null)
|
const pingTimer = ref(null)
|
||||||
const fitAddon = ref(null)
|
const fitAddon = ref(null)
|
||||||
// const searchBar = ref(null)
|
// const searchBar = ref(null)
|
||||||
const hasRegisterEvent = ref(false)
|
|
||||||
|
|
||||||
const socketConnected = ref(false)
|
const socketConnected = ref(false)
|
||||||
const curStatus = ref(CONNECTING)
|
const curStatus = ref(CONNECTING)
|
||||||
@ -72,6 +71,8 @@ const menuCollapse = computed(() => $store.menuCollapse)
|
|||||||
const quickCopy = computed(() => $store.terminalConfig.quickCopy)
|
const quickCopy = computed(() => $store.terminalConfig.quickCopy)
|
||||||
const quickPaste = computed(() => $store.terminalConfig.quickPaste)
|
const quickPaste = computed(() => $store.terminalConfig.quickPaste)
|
||||||
const autoExecuteScript = computed(() => $store.terminalConfig.autoExecuteScript)
|
const autoExecuteScript = computed(() => $store.terminalConfig.autoExecuteScript)
|
||||||
|
const autoReconnect = computed(() => $store.terminalConfig.autoReconnect)
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
const isLongPressCtrl = computed(() => props.longPressCtrl)
|
const isLongPressCtrl = computed(() => props.longPressCtrl)
|
||||||
const isLongPressAlt = computed(() => props.longPressAlt)
|
const isLongPressAlt = computed(() => props.longPressAlt)
|
||||||
|
|
||||||
@ -91,7 +92,6 @@ watch(theme, () => {
|
|||||||
watch(fontSize, () => {
|
watch(fontSize, () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
terminal.value.options.fontSize = fontSize.value
|
terminal.value.options.fontSize = fontSize.value
|
||||||
// fitAddon.value.fit()
|
|
||||||
handleResize()
|
handleResize()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -116,14 +116,16 @@ watch(curStatus, () => {
|
|||||||
|
|
||||||
const getCommand = async () => {
|
const getCommand = async () => {
|
||||||
let { data } = await $api.getCommand(hostId.value)
|
let { data } = await $api.getCommand(hostId.value)
|
||||||
if (data) command.value = data
|
if (data) initCommand.value = data
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectIO = () => {
|
const connectIO = () => {
|
||||||
|
curStatus.value = CONNECTING
|
||||||
socket.value = io($serviceURI, {
|
socket.value = io($serviceURI, {
|
||||||
path: '/terminal',
|
path: '/terminal',
|
||||||
forceNew: false,
|
forceNew: false,
|
||||||
reconnectionAttempts: 1
|
reconnection: false,
|
||||||
|
reconnectionAttempts: 0
|
||||||
})
|
})
|
||||||
socket.value.on('connect', () => {
|
socket.value.on('connect', () => {
|
||||||
console.log('/terminal socket已连接:', hostId.value)
|
console.log('/terminal socket已连接:', hostId.value)
|
||||||
@ -131,20 +133,14 @@ const connectIO = () => {
|
|||||||
socketConnected.value = true
|
socketConnected.value = true
|
||||||
socket.value.emit('create', { hostId: hostId.value, token: token.value })
|
socket.value.emit('create', { hostId: hostId.value, token: token.value })
|
||||||
socket.value.on('connect_terminal_success', () => {
|
socket.value.on('connect_terminal_success', () => {
|
||||||
if (hasRegisterEvent.value) return // 以下事件连接成功后仅可注册一次, 否则会多次触发. 除非socket重连
|
|
||||||
hasRegisterEvent.value = true
|
|
||||||
|
|
||||||
socket.value.on('output', (str) => {
|
socket.value.on('output', (str) => {
|
||||||
term.value.write(str)
|
term.value.write(str)
|
||||||
terminalText.value += str
|
terminalText.value += str
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.value.on('connect_shell_success', () => {
|
socket.value.on('connect_shell_success', () => {
|
||||||
curStatus.value = CONNECT_SUCCESS
|
curStatus.value = CONNECT_SUCCESS
|
||||||
onResize()
|
shellResize()
|
||||||
onFindText()
|
if (initCommand.value) socket.value.emit('input', initCommand.value + '\n')
|
||||||
onWebLinks()
|
|
||||||
if (command.value) socket.value.emit('input', command.value + '\n')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// socket.value.on('terminal_command_history', (data) => {
|
// socket.value.on('terminal_command_history', (data) => {
|
||||||
@ -155,7 +151,7 @@ const connectIO = () => {
|
|||||||
|
|
||||||
if (pingTimer.value) clearInterval(pingTimer.value)
|
if (pingTimer.value) clearInterval(pingTimer.value)
|
||||||
pingTimer.value = setInterval(() => {
|
pingTimer.value = setInterval(() => {
|
||||||
socket.value.emit('get_ping', host.value)
|
socket.value?.emit('get_ping', host.value)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
socket.value.emit('get_ping', host.value) // 获取服务端到客户端的ping值
|
socket.value.emit('get_ping', host.value) // 获取服务端到客户端的ping值
|
||||||
socket.value.on('ping_data', (pingMs) => {
|
socket.value.on('ping_data', (pingMs) => {
|
||||||
@ -167,50 +163,80 @@ const connectIO = () => {
|
|||||||
$router.push('/login')
|
$router.push('/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.value.on('terminal_print_info', (msg) => {
|
||||||
|
term.value.write(`${ msg }\r\n`)
|
||||||
|
})
|
||||||
|
|
||||||
socket.value.on('connect_close', () => {
|
socket.value.on('connect_close', () => {
|
||||||
if (curStatus.value === CONNECT_FAIL) return // 连接失败不需要自动重连
|
|
||||||
curStatus.value = RECONNECTING
|
|
||||||
console.warn('连接断开,3秒后自动重连: ', hostId.value)
|
|
||||||
term.value.write('\r\n连接断开,3秒后自动重连...\r\n')
|
|
||||||
socket.value.emit('reconnect_terminal')
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.value.on('reconnect_terminal_success', () => {
|
|
||||||
curStatus.value = CONNECT_SUCCESS
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.value.on('create_fail', (message) => {
|
|
||||||
curStatus.value = CONNECT_FAIL
|
curStatus.value = CONNECT_FAIL
|
||||||
console.error('n创建失败:', hostId.value, message)
|
term.value.write('\r\n\x1b[91m终端主动断开连接, 回车重新发起连接\x1b[0m')
|
||||||
term.value.write(`\r\n创建失败: ${ message }\r\n`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.value.on('connect_fail', (message) => {
|
socket.value.on('connect_terminal_fail', (message) => {
|
||||||
curStatus.value = CONNECT_FAIL
|
curStatus.value = CONNECT_FAIL
|
||||||
console.error('连接失败:', hostId.value, message)
|
term.value.write(`\r\n\x1b[91m连接终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
|
||||||
term.value.write(`\r\n连接失败: ${ message }\r\n`)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.value.on('disconnect', () => {
|
socket.value.on('create_terminal_fail', (message) => {
|
||||||
console.warn('terminal websocket 连接断开')
|
|
||||||
socket.value.removeAllListeners() // 取消所有监听
|
|
||||||
// socket.value.off('output') // 取消output监听,取消onData输入监听,重新注册
|
|
||||||
curStatus.value = CONNECT_FAIL
|
curStatus.value = CONNECT_FAIL
|
||||||
socketConnected.value = false
|
term.value.write(`\r\n\x1b[91m创建终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
|
||||||
term.value.write('\r\nError: 与面板socket连接断开。请关闭此tab,并检查本地与面板连接是否稳定\r\n')
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.value.on('disconnect', (reason) => {
|
||||||
|
console.warn('terminal websocket 连接断开:', reason)
|
||||||
|
switch (reason) {
|
||||||
|
case 'io server disconnect':
|
||||||
|
reconnectTerminal(true, '服务端主动断开连接')
|
||||||
|
break
|
||||||
|
case 'io client disconnect': // 客户端主动断开连接
|
||||||
|
break
|
||||||
|
case 'transport close':
|
||||||
|
reconnectTerminal(true, '本地网络连接异常')
|
||||||
|
break
|
||||||
|
case 'transport error':
|
||||||
|
reconnectTerminal(true, '建立连接错误')
|
||||||
|
break
|
||||||
|
case 'parse error':
|
||||||
|
reconnectTerminal(true, '数据解析错误')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
reconnectTerminal(true, '连接意外断开')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.value.on('connect_error', (err) => {
|
socket.value.on('connect_error', (err) => {
|
||||||
console.error('terminal websocket 连接错误:', err)
|
console.error('EasyNode服务端连接错误:', err)
|
||||||
|
curStatus.value = CONNECT_FAIL
|
||||||
|
term.value.write('\r\n\x1b[91mError: 连接失败,请检查EasyNode服务端是否正常, 回车重新发起连接\x1b[0m \r\n')
|
||||||
$notification({
|
$notification({
|
||||||
title: '终端连接失败',
|
title: '连接失败',
|
||||||
message: '请检查socket服务是否正常',
|
message: '请检查EasyNode服务端是否正常',
|
||||||
type: 'error'
|
type: 'error'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reconnectTerminal = (isCommonTips = false, tips) => {
|
||||||
|
socket.value.removeAllListeners()
|
||||||
|
socket.value.close()
|
||||||
|
socket.value = null
|
||||||
|
curStatus.value = CONNECT_FAIL
|
||||||
|
socketConnected.value = false
|
||||||
|
if (isCommonTips) {
|
||||||
|
if (isPlusActive.value && autoReconnect.value) {
|
||||||
|
term.value.write(`\r\n\x1b[91m${ tips },自动重连中...\x1b[0m \r\n`)
|
||||||
|
connectIO()
|
||||||
|
} else {
|
||||||
|
term.value.write(`\r\n\x1b[91mError: ${ tips },请重新连接。([功能项->本地设置->快捷操作]中开启自动重连)\x1b[0m \r\n`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
term.value.write(`\n${ tips } \n`)
|
||||||
|
connectIO()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createLocalTerminal = () => {
|
const createLocalTerminal = () => {
|
||||||
let terminalInstance = new Terminal({
|
let terminalInstance = new Terminal({
|
||||||
rendererType: 'dom',
|
rendererType: 'dom',
|
||||||
@ -223,13 +249,6 @@ const createLocalTerminal = () => {
|
|||||||
fontFamily: 'Cascadia Code, Menlo, monospace',
|
fontFamily: 'Cascadia Code, Menlo, monospace',
|
||||||
fontSize: fontSize.value,
|
fontSize: fontSize.value,
|
||||||
theme: theme.value
|
theme: theme.value
|
||||||
// {
|
|
||||||
// foreground: '#ECECEC',
|
|
||||||
// background: '#000000', // 'transparent',
|
|
||||||
// // cursor: 'help',
|
|
||||||
// selection: '#ff9900',
|
|
||||||
// lineHeight: 20
|
|
||||||
// }
|
|
||||||
})
|
})
|
||||||
term.value = terminalInstance
|
term.value = terminalInstance
|
||||||
terminalInstance.open(terminalRef.value)
|
terminalInstance.open(terminalRef.value)
|
||||||
@ -237,15 +256,20 @@ const createLocalTerminal = () => {
|
|||||||
terminalInstance.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
|
terminalInstance.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
|
||||||
terminalInstance.focus()
|
terminalInstance.focus()
|
||||||
onSelectionChange()
|
onSelectionChange()
|
||||||
|
onFindText()
|
||||||
|
onWebLinks()
|
||||||
|
onResize()
|
||||||
terminal.value = terminalInstance
|
terminal.value = terminalInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shellResize = () => {
|
||||||
|
fitAddon.value.fit()
|
||||||
|
let { rows, cols } = term.value
|
||||||
|
socket.value?.emit('resize', { rows, cols })
|
||||||
|
}
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
fitAddon.value = new FitAddon()
|
fitAddon.value = new FitAddon()
|
||||||
term.value.loadAddon(fitAddon.value)
|
term.value.loadAddon(fitAddon.value)
|
||||||
fitAddon.value.fit()
|
|
||||||
let { rows, cols } = term.value
|
|
||||||
socket.value.emit('resize', { rows, cols })
|
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,10 +283,7 @@ const handleResize = () => {
|
|||||||
temp[index] = item.style.display
|
temp[index] = item.style.display
|
||||||
item.style.display = 'block'
|
item.style.display = 'block'
|
||||||
})
|
})
|
||||||
fitAddon.value?.fit()
|
shellResize()
|
||||||
let { rows, cols } = term.value
|
|
||||||
socket.value?.emit('resize', { rows, cols })
|
|
||||||
|
|
||||||
panes.forEach((item, index) => {
|
panes.forEach((item, index) => {
|
||||||
item.style.display = temp[index]
|
item.style.display = temp[index]
|
||||||
})
|
})
|
||||||
@ -333,7 +354,13 @@ function extractLastCdPath(text) {
|
|||||||
|
|
||||||
const onData = () => {
|
const onData = () => {
|
||||||
term.value.onData((key) => {
|
term.value.onData((key) => {
|
||||||
if (socketConnected.value === false) return
|
if (!socket.value || !socketConnected.value) return
|
||||||
|
|
||||||
|
if ('\r' === key && curStatus.value === CONNECT_FAIL) {
|
||||||
|
reconnectTerminal(false, '重新连接中...')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isLongPressCtrl.value || isLongPressAlt.value) {
|
if (isLongPressCtrl.value || isLongPressAlt.value) {
|
||||||
const keyCode = key.toUpperCase().charCodeAt(0)
|
const keyCode = key.toUpperCase().charCodeAt(0)
|
||||||
console.log('keyCode: ', keyCode)
|
console.log('keyCode: ', keyCode)
|
||||||
@ -354,12 +381,6 @@ const onData = () => {
|
|||||||
enterTimer.value = setTimeout(() => {
|
enterTimer.value = setTimeout(() => {
|
||||||
if (enterTimer.value) clearTimeout(enterTimer.value)
|
if (enterTimer.value) clearTimeout(enterTimer.value)
|
||||||
if (key === '\r') { // Enter
|
if (key === '\r') { // Enter
|
||||||
if (curStatus.value === CONNECT_FAIL) { // 连接失败&&未正在连接,按回车可触发重连
|
|
||||||
curStatus.value = CONNECTING
|
|
||||||
term.value.write('\r\n连接中...\r\n')
|
|
||||||
socket.value.emit('reconnect_terminal')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (curStatus.value === CONNECT_SUCCESS) {
|
if (curStatus.value === CONNECT_SUCCESS) {
|
||||||
let cleanText = applyBackspace(filterAnsiSequences(terminalText.value))
|
let cleanText = applyBackspace(filterAnsiSequences(terminalText.value))
|
||||||
const lines = cleanText.split('\n')
|
const lines = cleanText.split('\n')
|
||||||
@ -369,7 +390,6 @@ const onData = () => {
|
|||||||
// 截取最后一个提示符后的内容('$'或'#'后的内容)
|
// 截取最后一个提示符后的内容('$'或'#'后的内容)
|
||||||
const commandStartIndex = lastLine.lastIndexOf('#') + 1
|
const commandStartIndex = lastLine.lastIndexOf('#') + 1
|
||||||
const commandText = lastLine.substring(commandStartIndex).trim()
|
const commandText = lastLine.substring(commandStartIndex).trim()
|
||||||
// console.log('Processed command: ', commandText)
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const cdPath = extractLastCdPath(commandText)
|
const cdPath = extractLastCdPath(commandText)
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@
|
|||||||
<el-tooltip
|
<el-tooltip
|
||||||
effect="dark"
|
effect="dark"
|
||||||
content="开启后同步键盘输入到所有会话"
|
content="开启后同步键盘输入到所有会话"
|
||||||
placement="top"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="isSyncAllSession"
|
v-model="isSyncAllSession"
|
||||||
@ -89,7 +89,7 @@
|
|||||||
<el-tooltip
|
<el-tooltip
|
||||||
effect="dark"
|
effect="dark"
|
||||||
content="SFTP文件传输"
|
content="SFTP文件传输"
|
||||||
placement="top"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="showSftp"
|
v-model="showSftp"
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-show="!isAllConfssh" fixed="right" width="80px">
|
<el-table-column fixed="right" width="80px">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="actios_btns">
|
<div class="actios_btns">
|
||||||
<el-button
|
<el-button
|
||||||
@ -71,9 +71,6 @@ const route = useRoute()
|
|||||||
|
|
||||||
let showLinkTips = computed(() => !Boolean(terminalTabs.length))
|
let showLinkTips = computed(() => !Boolean(terminalTabs.length))
|
||||||
let hostList = computed(() => $store.hostList)
|
let hostList = computed(() => $store.hostList)
|
||||||
let isAllConfssh = computed(() => {
|
|
||||||
return hostList.value?.every(item => item.isConfig)
|
|
||||||
})
|
|
||||||
|
|
||||||
function linkTerminal(hostInfo) {
|
function linkTerminal(hostInfo) {
|
||||||
let targetHost = hostList.value.find(item => item.id === hostInfo.id)
|
let targetHost = hostList.value.find(item => item.id === hostInfo.id)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user