mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 12:48:28 +08:00
feat: 功能栏新增docker容器视图
This commit is contained in:
@@ -16,3 +16,4 @@ plan.md
|
||||
local-script
|
||||
版本发布.md
|
||||
.vscode
|
||||
.windsurfrules
|
||||
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ const { httpPort } = require('./config')
|
||||
const middlewares = require('./middlewares')
|
||||
const wsTerminal = require('./socket/terminal')
|
||||
const wsSftp = require('./socket/sftp')
|
||||
const wsDocker = require('./socket/docker')
|
||||
const wsClientInfo = require('./socket/clients')
|
||||
const wsOnekey = require('./socket/onekey')
|
||||
const { throwError } = require('./utils/tools')
|
||||
@@ -24,6 +25,7 @@ function serverHandler(app, server) {
|
||||
app.proxy = true // 用于nginx反代时获取真实客户端ip
|
||||
wsTerminal(server) // 终端
|
||||
wsSftp(server) // sftp
|
||||
wsDocker(server) // docker
|
||||
wsOnekey(server) // 一键指令
|
||||
wsClientInfo(server) // 客户端信息
|
||||
app.context.throwError = throwError // 常用方法挂载全局ctx上
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const path = require('path')
|
||||
const { Server } = require('socket.io')
|
||||
const { Client: SSHClient } = require('ssh2')
|
||||
const { verifyAuthSync } = require('../utils/verify-auth')
|
||||
const { isAllowedIp } = require('../utils/tools')
|
||||
const { createTerminal } = require('./terminal')
|
||||
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||
|
||||
module.exports = (httpServer) => {
|
||||
const serverIo = new Server(httpServer, {
|
||||
path: '/docker',
|
||||
cors: {
|
||||
origin: '*'
|
||||
}
|
||||
})
|
||||
|
||||
let connectionCount = 0
|
||||
|
||||
serverIo.on('connection', (socket) => {
|
||||
connectionCount++
|
||||
consola.success(`docker websocket 已连接 - 当前连接数: ${ connectionCount }`)
|
||||
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
||||
if (!isAllowedIp(requestIP)) {
|
||||
socket.emit('ip_forbidden', 'IP地址不在白名单中')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
consola.success('docker websocket 已连接')
|
||||
let targetSSHClient = null
|
||||
let jumpSshClients = []
|
||||
socket.on('ws_docker', async ({ hostId, token }) => {
|
||||
const { code } = await verifyAuthSync(token, requestIP)
|
||||
if (code !== 1) {
|
||||
socket.emit('token_verify_fail')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
targetSSHClient = new SSHClient()
|
||||
let { jumpSshClients: dockerJumpSshClients } = await createTerminal(hostId, socket, targetSSHClient, false)
|
||||
jumpSshClients.push(...dockerJumpSshClients)
|
||||
let { getDockerContainers = null, getDockerLogs = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||
if (!getDockerContainers || !getDockerLogs) {
|
||||
socket.emit('docker_not_plus')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
let containersData = await getDockerContainers(targetSSHClient)
|
||||
if (!containersData) {
|
||||
socket.emit('docker_connect_fail')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
socket.emit('docker_containers_data', containersData)
|
||||
socket.on('docker_get_containers_data', async () => {
|
||||
socket.emit('docker_containers_data', await getDockerContainers(targetSSHClient))
|
||||
})
|
||||
|
||||
socket.on('docker_get_containers_logs', async ({ containerId, tail = 1000 }) => {
|
||||
socket.emit('docker_containers_logs', await getDockerLogs(targetSSHClient, containerId, tail))
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
connectionCount--
|
||||
targetSSHClient && targetSSHClient.end()
|
||||
jumpSshClients?.forEach(sshClient => sshClient && sshClient.end())
|
||||
targetSSHClient = null
|
||||
jumpSshClients = null
|
||||
consola.info(`docker websocket 连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -107,7 +107,7 @@ module.exports = (httpServer) => {
|
||||
return
|
||||
}
|
||||
isExecuting = true
|
||||
socket.on('create', async ({ hostIds, token, command, timeout }) => {
|
||||
socket.on('ws_onekey', async ({ hostIds, token, command, timeout }) => {
|
||||
const { code } = await verifyAuthSync(token, requestIP)
|
||||
if (code !== 1) {
|
||||
socket.emit('token_verify_fail')
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -224,7 +224,7 @@ module.exports = (httpServer) => {
|
||||
let sftpClient = new SFTPClient()
|
||||
consola.success('terminal websocket 已连接')
|
||||
|
||||
socket.on('create', async ({ hostId, token }) => {
|
||||
socket.on('ws_sftp', async ({ hostId, token }) => {
|
||||
const { code } = await verifyAuthSync(token, requestIP)
|
||||
consola.log('code:', code)
|
||||
if (code !== 1) {
|
||||
|
||||
@@ -43,12 +43,13 @@ function createInteractiveShell(socket, targetSSHClient) {
|
||||
consola.info('交互终端已关闭')
|
||||
targetSSHClient.end()
|
||||
})
|
||||
socket.emit('connect_shell_success') // 已连接终端,web端可以执行指令了
|
||||
socket.emit('terminal_connect_shell_success') // 已连接终端,web端可以执行指令了
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function createTerminal(hostId, socket, targetSSHClient) {
|
||||
async function createTerminal(hostId, socket, targetSSHClient, isInteractiveShell = true) {
|
||||
consola.info(`准备创建${ isInteractiveShell ? '交互式' : '非交互式' }终端:${ hostId }`)
|
||||
return new Promise(async (resolve) => {
|
||||
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
|
||||
if (!targetHostInfo) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
|
||||
@@ -65,30 +66,34 @@ async function createTerminal(hostId, socket, targetSSHClient) {
|
||||
|
||||
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 })
|
||||
|
||||
let closeNoticeFlag = false // 避免重复发送通知
|
||||
targetSSHClient
|
||||
.on('ready', async () => {
|
||||
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录成功`)
|
||||
socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`)
|
||||
consola.success('终端连接成功:', host)
|
||||
socket.emit('connect_terminal_success', `终端连接成功:${ host }`)
|
||||
let stream = await createInteractiveShell(socket, targetSSHClient)
|
||||
resolve({ stream, jumpSshClients })
|
||||
if (isInteractiveShell) {
|
||||
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录成功`)
|
||||
socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`)
|
||||
socket.emit('terminal_connect_success', `终端连接成功:${ host }`)
|
||||
let stream = await createInteractiveShell(socket, targetSSHClient)
|
||||
resolve({ stream, jumpSshClients })
|
||||
} else {
|
||||
resolve({ jumpSshClients })
|
||||
}
|
||||
})
|
||||
.on('close', (err) => {
|
||||
if (closeNoticeFlag) return closeNoticeFlag = false
|
||||
const closeReason = err ? '发生错误导致连接断开' : '正常断开连接'
|
||||
consola.info(`终端连接断开(${ closeReason }): ${ host }`)
|
||||
socket.emit('connect_close', { reason: closeReason })
|
||||
socket.emit('terminal_connect_close', { reason: closeReason })
|
||||
})
|
||||
.on('error', (err) => {
|
||||
closeNoticeFlag = true
|
||||
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录失败`)
|
||||
consola.error('连接终端失败:', host, err.message)
|
||||
socket.emit('connect_terminal_fail', err.message)
|
||||
socket.emit('terminal_connect_fail', err.message)
|
||||
})
|
||||
.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) {
|
||||
finish([targetConnectionOptions[authType]])
|
||||
@@ -100,7 +105,7 @@ async function createTerminal(hostId, socket, targetSSHClient) {
|
||||
})
|
||||
} catch (err) {
|
||||
consola.error('创建终端失败: ', host, err.message)
|
||||
socket.emit('create_terminal_fail', err.message)
|
||||
socket.emit('terminal_create_fail', err.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -127,7 +132,7 @@ module.exports = (httpServer) => {
|
||||
consola.success('terminal websocket 已连接')
|
||||
let targetSSHClient = null
|
||||
let jumpSshClients = []
|
||||
socket.on('create', async ({ hostId, token }) => {
|
||||
socket.on('ws_terminal', async ({ hostId, token }) => {
|
||||
const { code } = await verifyAuthSync(token, requestIP)
|
||||
if (code !== 1) {
|
||||
socket.emit('token_verify_fail')
|
||||
@@ -135,7 +140,7 @@ module.exports = (httpServer) => {
|
||||
return
|
||||
}
|
||||
targetSSHClient = new SSHClient()
|
||||
let { stream = null, jumpSshClients: jumpSshClientsFromCreate } = await createTerminal(hostId, socket, targetSSHClient)
|
||||
let { stream = null, jumpSshClients: jumpSshClientsFromCreate } = await createTerminal(hostId, socket, targetSSHClient, true)
|
||||
jumpSshClients = jumpSshClientsFromCreate
|
||||
function listenerInput(key) {
|
||||
if (targetSSHClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
|
||||
@@ -168,3 +173,4 @@ module.exports = (httpServer) => {
|
||||
}
|
||||
|
||||
module.exports.getConnectionOptions = getConnectionOptions
|
||||
module.exports.createTerminal = createTerminal
|
||||
|
||||
@@ -1 +1 @@
|
||||
U2FsdGVkX18X5TRtUbdmc/+svI+ZQG53eCr1n5cNf1qpcB8oPWdgGLP0LUkMxAygavkY8tr/lLH4pgVRXcMXYN7OkS+q6B7O+BXXJtbJpNOa1pGIgGCJ1WHk3eiSMupSXhEfPPfv4eIsaMrGpAPIWsCg1H5u16KpeaP7MykpmbyHEzVrXIbddeiBneXySl5uNOiRk8qw/JQYCZSrt6D+Sp3xlBsiVyLwGkHxlcRWlVoe5VRkIgMtL9q+Dego/nk1r56iqIkNmnP+kmwPY2wMeOT6lPwYbeRlmdD9VRGRAkgrg1waKqvK5qOMYCMCVDubhvQ5ulD5JtGM0SgzlgWhYoias7HC7AM9FLd8+rUZYw1BXZ/J9eZu2H1gzmClEDWMeHcQuEPPddyUuP9q/hsADGsxxPMMvquEtuedzcx3FQN1dDVqDlkPNAW/DWZ9eB717gcS2jmQlCk+OdyAvlZhTyj/4GhThVrEc5FZo63Y9J7oE4p70/1NCWlnRUKAqBibCeiJkFHR7zcdlLKmUCgO1rXz+PjqSwemG+RJ4tGb1GLdztA6APB17xsBgI3hISM2LSDov/snkYKDgIxYkQctoKGF/c6TLeYYd1YKxWJ4cCeYB+Gko/bH2c+AHMbECKgxZGfCZOJaD8ksY0qRIllKAq52rPeb5kbaNikhkUnvqmCMqWdr7mi8JYHqzW7ys0hp5H6/8tyWxVrSh9EP2hXbpCQF3Wk+0zDt5Dflkiriw8xTr1mCOuSYA1C1M4PyKuaG60DUbLl7WqRxFAd87qwWC4A5fSfMlyWcu2JmZ9M6LfHY7BqKSS0nGQ9YEOwCPol00zeczmEs2WiP+5Esf0U/ZxluCHAmNbJI9ln5AR2RZDc=
|
||||
U2FsdGVkX1/0WBuJm6/aJntcPq4eyOfQj6zUiDepQcZ+XEkvnCDV/v/x4NBb2uolBryIiLf/dOVkGwMtm0FrtWwvbplESnlHkeAXAPJFQFZSFJfhAAlLobIIHhkCEVEHnA7oBE9GkobuM5yXFNQE0ciYNHkwRkAxk+CdXsdlaTKdnFyInWQ92ljQcZVzr7sW8wnNNkKk9WhmdvsvFtljfkA3YY7AMtEiZLAOdqCpaC5WIzP7sos8C/cmdeY9jmkmtZr79NnCWsyV5MWQB9jvKk7pk0o8wmnlGhsmACyJzzRdzFXFOTouh+pXloohb/uVHx/v2yrrWHjpwwPIbM1d462/iEWv5t4cOHlGyhze0Po26y7QCwId3M2m/nCB726wlKQl6KMjhoavVOGKrkRp4tfLtKb/wgauyQAUSDgVmCAesCVkxS7q+z5u+GJLoAgQDJbnHG0AQxF1lQK3+XrcrO0qr98omnHUOkuBVdPvfZN87HlW20khMEzYW95z9gS2BmlJ+1hyEcOhgIPpG03GAuE6A7NHTpAmWjH9pQeQ8ZhyjywpEEHGRUUgZWuN9s1Mh60sw+JzjI0gXMuX0FfrG3W2zmQa6XB2RjULrYVMNRUDrpOrP2/s8uz9qL/S0dzNsUDsEWgwUgYhW075z932xLhQIJ087WeqHXO0QZ35OSib3I1EaSKIADWgvs1dz5xuLjXBWKaNewFS6w0+CCm6Hg4QUvZeX+9gyNygynEZX1Oe2RJX0hnfYoa9ZbnbB3vGd/Ka59Wi58YOKyIRGV5YX9lRPqdN0OyLxzFhAXqR8DXabvJgaidsJEvyxpP7odpBgMuPb4KO5axeuvlFLp1GrxbRpv6quEhjFCSLQyQMJ8o=
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "3.0.6",
|
||||
"version": "3.0.7",
|
||||
"description": "easynode-server",
|
||||
"bin": "./bin/www",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.0.6",
|
||||
"version": "3.0.7",
|
||||
"description": "easynode-web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
<codemirror
|
||||
v-model="code"
|
||||
placeholder="Code goes here..."
|
||||
:style="{ height: '79vh', minHeight: '500px' }"
|
||||
:style="{ height: '80vh', minHeight: '500px' }"
|
||||
:disabled="disabled"
|
||||
:autofocus="true"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="4"
|
||||
@@ -31,7 +32,7 @@
|
||||
/>
|
||||
<template #footer>
|
||||
<footer>
|
||||
<div class="select_wrap">
|
||||
<div v-if="!disabled" class="select_wrap">
|
||||
<el-select v-model="curLang" placeholder="Select language">
|
||||
<el-option
|
||||
v-for="item in languageKey"
|
||||
@@ -42,7 +43,14 @@
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="footer_btns">
|
||||
<el-button type="primary" :loading="loading" @click="handleSave">保存</el-button>
|
||||
<el-button
|
||||
v-if="!disabled"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
<el-button type="info" @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -76,6 +84,10 @@ export default {
|
||||
filename: {
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['update:show', 'save', 'closed',],
|
||||
@@ -86,7 +98,7 @@ export default {
|
||||
status: '准备中',
|
||||
loading: false,
|
||||
isTips: false,
|
||||
code: 'hello world'
|
||||
code: 'loading...'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -139,6 +151,7 @@ export default {
|
||||
case 'sql': return this.curLang = 'sql'
|
||||
case 'md': return this.curLang = 'markdown'
|
||||
case 'py': return this.curLang = 'python'
|
||||
case 'log': return this.curLang = 'json'
|
||||
default:
|
||||
// console.log('不支持的文件类型: ', newVal)
|
||||
// console.log('默认: ', 'shell')
|
||||
@@ -172,7 +185,7 @@ export default {
|
||||
this.$emit('closed')
|
||||
},
|
||||
handleClose() {
|
||||
if (this.isTips) {
|
||||
if (this.isTips && !this.disabled) {
|
||||
this.$messageBox.confirm('文件已变更, 确认丢弃?', 'Warning', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
@@ -236,7 +249,4 @@ export default {
|
||||
width: 150px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.footer_btns {
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div v-show="!isPlusActive" class="plus_limit_container">
|
||||
<div class="plus_limit_content">
|
||||
<el-icon class="lock-icon"><Lock /></el-icon>
|
||||
<div class="plus_label">PLUS</div>
|
||||
<p>此功能仅限PLUS版使用, <span class="to_active" @click="gotoPlusPage">去激活</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
|
||||
const { proxy: { $store } } = getCurrentInstance()
|
||||
const router = useRouter()
|
||||
|
||||
const isPlusActive = computed(() => $store.isPlusActive)
|
||||
|
||||
const gotoPlusPage = () => {
|
||||
router.push('/setting?tabKey=plus')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plus_limit_container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.plus_limit_content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
|
||||
.lock-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.plus_label {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
.to_active {
|
||||
color: #ffd700;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -259,7 +259,7 @@ const createExecShell = (hostIds = [], command = 'ls', timeout = 60) => {
|
||||
pendingRecord.value = [] // 每轮执行前清空
|
||||
})
|
||||
|
||||
socket.value.emit('create', { hostIds, token: token.value, command, timeout })
|
||||
socket.value.emit('ws_onekey', { hostIds, token: token.value, command, timeout })
|
||||
|
||||
socket.value.on('output', (result) => {
|
||||
loading.value = false
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<div class="docker_container">
|
||||
<PlusLimitTip />
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="dockerContainers"
|
||||
row-key="id"
|
||||
size="small"
|
||||
center
|
||||
>
|
||||
<el-table-column
|
||||
prop="id"
|
||||
label="ID"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="name"
|
||||
label="名称"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="image"
|
||||
label="镜像"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="ip"
|
||||
label="IP 地址"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="ports"
|
||||
label="端口"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-if="Array.isArray(row.ports)">
|
||||
<el-tooltip placement="left">
|
||||
<template #content>
|
||||
<div v-for="port in row.ports" :key="port">
|
||||
{{ port }}
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<div v-for="port in row.ports.slice(0, 1)" :key="port">
|
||||
{{ port }}
|
||||
<span v-if="row.ports.length > 1">...</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row.ports || '--' }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资源使用率" width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="resource_usage">
|
||||
<el-tooltip placement="left">
|
||||
<template #content>
|
||||
<div>
|
||||
<div>CPU 使用: {{ row.cpuUsage }}%</div>
|
||||
<div>内存使用: {{ row.memoryUsed }}</div>
|
||||
<div>内存限额: {{ row.memoryLimit }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="resource_item">
|
||||
<div>CPU: {{ row.cpuUsage }}%</div>
|
||||
<div>内存: {{ row.memoryUsage }}%</div>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="getStatusType(row.status)"
|
||||
size="small"
|
||||
effect="light"
|
||||
style="width: 60px;"
|
||||
>
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="uptime" label="运行时长" />
|
||||
<el-table-column label="操作" align="center" width="230">
|
||||
<template #header>
|
||||
<div class="header_buttons">
|
||||
<div v-if="dockerServerErr" style="margin-right: 6px;">
|
||||
<el-tag type="danger" effect="light" size="small">Docker服务连接失败</el-tag>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
@click="() => refreshDockerContainers(true)"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="action_buttons">
|
||||
<el-tooltip content="启动" placement="top" :disabled="row.status === 'running'">
|
||||
<el-button
|
||||
type="success"
|
||||
:icon="VideoPlay"
|
||||
circle
|
||||
size="small"
|
||||
:disabled="row.status === 'running'"
|
||||
@click="handleStart(row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="重启" placement="top" :disabled="row.status !== 'running'">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="RefreshRight"
|
||||
circle
|
||||
size="small"
|
||||
:disabled="row.status !== 'running'"
|
||||
@click="handleRestart(row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="停止" placement="top" :disabled="row.status !== 'running'">
|
||||
<el-button
|
||||
type="warning"
|
||||
:icon="VideoPause"
|
||||
circle
|
||||
size="small"
|
||||
:disabled="row.status !== 'running'"
|
||||
@click="handleStop(row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
circle
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button
|
||||
type="info"
|
||||
class="more_button"
|
||||
:icon="MoreFilled"
|
||||
circle
|
||||
size="small"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleLogs(row)"> 查看日志 </el-dropdown-item>
|
||||
<el-dropdown-item @click="handleTerminal(row)"> 进入容器 </el-dropdown-item>
|
||||
<el-dropdown-item @click="handlePullImage(row)"> 拉取新镜像 </el-dropdown-item>
|
||||
<el-dropdown-item @click="handleMakeImage(row)"> 制作成镜像 </el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<CodeEdit
|
||||
v-model:show="showLogsDialog"
|
||||
:original-code="dockerLogs"
|
||||
:disabled="true"
|
||||
:filename="containerName"
|
||||
@closed="() => showLogsDialog = false"
|
||||
/>
|
||||
<el-dialog
|
||||
v-model="makeImageDialog"
|
||||
title="制作镜像"
|
||||
top="250px"
|
||||
width="450px"
|
||||
>
|
||||
<el-form
|
||||
ref="makeImageFormRef"
|
||||
:model="makeImageForm"
|
||||
:rules="makeImageRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="镜像名称" prop="name">
|
||||
<el-input v-model="makeImageForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签" prop="tag">
|
||||
<el-input v-model="makeImageForm.tag" />
|
||||
</el-form-item>
|
||||
<el-form-item label="作者" prop="author">
|
||||
<el-input v-model="makeImageForm.author" />
|
||||
</el-form-item>
|
||||
<el-form-item label="提交说明" prop="message">
|
||||
<el-input v-model="makeImageForm.message" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="makeImageDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleMakeImageConfirm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch, getCurrentInstance } from 'vue'
|
||||
import { VideoPlay, VideoPause, RefreshRight, Delete, MoreFilled } from '@element-plus/icons-vue'
|
||||
import CodeEdit from '@/components/code-edit/index.vue'
|
||||
import PlusLimitTip from '@/components/common/PlusLimitTip.vue'
|
||||
import socketIo from 'socket.io-client'
|
||||
import { EventBus } from '@/utils'
|
||||
|
||||
const { io } = socketIo
|
||||
const { proxy: { $store, $serviceURI, $message, $messageBox } } = getCurrentInstance()
|
||||
|
||||
const props = defineProps({
|
||||
hostId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const socket = ref(null)
|
||||
const loading = ref(false)
|
||||
const dockerContainers = ref([])
|
||||
const dockerServerErr = ref(false)
|
||||
const dockerLogs = ref('')
|
||||
const currentContainer = ref(null)
|
||||
const logInterval = ref(null)
|
||||
const showLogsDialog = ref(false)
|
||||
const makeImageDialog = ref(false)
|
||||
const makeImageFormRef = ref(null)
|
||||
const makeImageForm = ref({
|
||||
name: '',
|
||||
tag: 'latest',
|
||||
author: '',
|
||||
message: ''
|
||||
})
|
||||
const makeImageRules = ref({
|
||||
name: [
|
||||
{ required: true, message: '请输入镜像名称', trigger: 'blur' },
|
||||
],
|
||||
tag: [
|
||||
{ required: false, message: '请输入标签', trigger: 'blur' },
|
||||
],
|
||||
author: [
|
||||
{ required: false, message: '请输入作者', trigger: 'blur' },
|
||||
],
|
||||
message: [
|
||||
{ required: false, message: '请输入提交说明', trigger: 'blur' },
|
||||
]
|
||||
})
|
||||
const token = computed(() => $store.token)
|
||||
const hostId = computed(() => props.hostId)
|
||||
const containerName = computed(() => `${ currentContainer.value?.name }.log(自动刷新)` || '')
|
||||
const isPlusActive = computed(() => $store.isPlusActive)
|
||||
|
||||
watch(() => showLogsDialog.value, (newVal) => {
|
||||
if (!newVal) {
|
||||
dockerLogs.value = ''
|
||||
clearInterval(logInterval.value)
|
||||
return
|
||||
}
|
||||
logInterval.value = setInterval(() => {
|
||||
intervalLogs()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
watch(() => isPlusActive.value, (newVal) => {
|
||||
if (!newVal) {
|
||||
dockerContainers.value = [{
|
||||
'id': 'c36daaaa5cbaa3',
|
||||
'name': 'easynode',
|
||||
'image': 'chaoszhu/easynode:latest',
|
||||
'status': 'running',
|
||||
'ports': [
|
||||
'0.0.0.0:8082->8082/tcp',
|
||||
],
|
||||
'createdAt': new Date().toLocaleString(),
|
||||
'uptime': '49 minutes',
|
||||
'cpuUsage': '0.17',
|
||||
'memoryUsage': '22.33',
|
||||
'memoryLimit': '1.796GiB',
|
||||
'memoryUsed': '410.6MiB',
|
||||
'ip': '171.18.0.2'
|
||||
},]
|
||||
}
|
||||
}, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
// 根据状态返回对应的类型
|
||||
const getStatusType = (status) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'exited':
|
||||
return 'danger'
|
||||
case 'paused':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const connectDocker = () => {
|
||||
socket.value = io($serviceURI, {
|
||||
path: '/docker',
|
||||
forceNew: false,
|
||||
reconnection: false,
|
||||
reconnectionAttempts: 0
|
||||
})
|
||||
socket.value.on('connect', () => {
|
||||
console.log('/docker socket已连接:', hostId.value)
|
||||
loading.value = true
|
||||
socket.value.emit('ws_docker', { hostId: hostId.value, token: token.value })
|
||||
socket.value.on('docker_containers_data', (data) => {
|
||||
// console.log('docker_containers_data:', data)
|
||||
dockerServerErr.value = false
|
||||
loading.value = false
|
||||
dockerContainers.value = data
|
||||
})
|
||||
socket.value.on('docker_containers_logs', (data) => {
|
||||
// console.log('docker_containers_logs:', data)
|
||||
dockerLogs.value = data
|
||||
showLogsDialog.value = true
|
||||
})
|
||||
socket.value.on('docker_connect_fail', () => {
|
||||
console.error('docker_connect_fail')
|
||||
dockerServerErr.value = true
|
||||
loading.value = false
|
||||
})
|
||||
socket.value.on('docker_not_plus', () => {
|
||||
console.warn('docker_not_plus')
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
|
||||
socket.value.on('disconnect', (reason) => {
|
||||
console.warn('docker websocket 连接断开:', reason)
|
||||
loading.value = false
|
||||
socket.value = null
|
||||
})
|
||||
}
|
||||
|
||||
const refreshDockerContainers = (isLoading = true, delay = 0) => {
|
||||
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
|
||||
if (!socket.value || !socket.value.connected) return connectDocker()
|
||||
loading.value = isLoading
|
||||
setTimeout(() => {
|
||||
socket.value.emit('docker_get_containers_data')
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const handleStart = async (row) => {
|
||||
$message.success('已发送启动指令')
|
||||
EventBus.$emit('exec_external_command', `docker start ${ row.id }`)
|
||||
refreshDockerContainers(false, 3000)
|
||||
}
|
||||
|
||||
const handleStop = async (row) => {
|
||||
$message.success('已发送停止指令')
|
||||
EventBus.$emit('exec_external_command', `docker stop ${ row.id }`)
|
||||
refreshDockerContainers(false, 3000)
|
||||
}
|
||||
|
||||
const handleRestart = async (row) => {
|
||||
$message.success('已发送重启指令')
|
||||
EventBus.$emit('exec_external_command', `docker restart ${ row.id }`)
|
||||
refreshDockerContainers(false, 3000)
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
$messageBox.confirm(`确认删除容器 ${ row.name }?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
$message.success('已发送删除指令')
|
||||
EventBus.$emit('exec_external_command', `docker rm -f ${ row.id }`)
|
||||
refreshDockerContainers(false, 3000)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleLogs = (row) => {
|
||||
if (!socket.value || !socket.value.connected) {
|
||||
$message.error('连接已断开,正在刷新')
|
||||
refreshDockerContainers(true, 0)
|
||||
return
|
||||
}
|
||||
currentContainer.value = row
|
||||
socket.value.emit('docker_get_containers_logs', { containerId: row.id, tail: 2000 })
|
||||
}
|
||||
|
||||
const handleTerminal = (row) => {
|
||||
$message.success('已发送进入容器指令')
|
||||
EventBus.$emit('exec_external_command', `docker exec -it ${ row.id } /bin/sh`)
|
||||
}
|
||||
|
||||
const handlePullImage = (row) => {
|
||||
$message.success('已发送拉取镜像指令')
|
||||
EventBus.$emit('exec_external_command', `docker pull ${ row.image }`)
|
||||
}
|
||||
|
||||
const handleMakeImage = (row) => {
|
||||
currentContainer.value = row
|
||||
makeImageDialog.value = true
|
||||
}
|
||||
|
||||
const handleMakeImageConfirm = () => {
|
||||
makeImageFormRef.value.validate((valid) => {
|
||||
if (!valid) return
|
||||
$message.success('已发送制作镜像指令')
|
||||
const { name, tag, author, message } = makeImageForm.value
|
||||
// docker commit -a "作者名" -m "提交说明" 容器ID my-new-image:v1.0
|
||||
EventBus.$emit('exec_external_command', `docker commit -a "${ author }" -m "${ message }" ${ currentContainer.value.id } ${ name }:${ tag }`)
|
||||
makeImageDialog.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const intervalLogs = () => {
|
||||
if (!socket.value || !socket.value.connected) {
|
||||
$message.error('连接已断开,正在刷新')
|
||||
clearInterval(logInterval.value)
|
||||
refreshDockerContainers(true, 0)
|
||||
return
|
||||
}
|
||||
socket.value.emit('docker_get_containers_logs', { containerId: currentContainer.value.id, tail: 2000 })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectDocker()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.docker_container {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
.resource_usage {
|
||||
.resource_item {
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.header_buttons {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action_buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
.more_button {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table tr), :deep(.el-table th.el-table__cell) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-tag) {
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,9 @@
|
||||
<el-tab-pane label="脚本库" name="script">
|
||||
<ScriptInput :host-id="hostId" @exec-command="execCommand" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="容器管理" name="docker">
|
||||
<Docker :host-id="hostId" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,6 +22,7 @@ import { ref, onMounted, nextTick } from 'vue'
|
||||
import { EventBus } from '@/utils'
|
||||
import Sftp from './sftp.vue'
|
||||
import ScriptInput from './script-input.vue'
|
||||
import Docker from './docker.vue'
|
||||
|
||||
defineProps({
|
||||
hostId: {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="script_input_container">
|
||||
<div v-show="!isPlusActive" class="plus_tips">
|
||||
<div class="plus_tips_content">
|
||||
<el-icon class="lock-icon"><Lock /></el-icon>
|
||||
<div class="plus_label">PLUS</div>
|
||||
<p>此功能仅限PLUS版使用, <span class="to_active" @click="gotoPlusPage">去激活</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<PlusLimitTip />
|
||||
<div class="left_box">
|
||||
<div class="group_list">
|
||||
<div
|
||||
@@ -133,9 +127,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, getCurrentInstance } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { VideoPlay, Edit, Setting, Plus, ArrowRightBold, Lock } from '@element-plus/icons-vue'
|
||||
import { VideoPlay, Edit, Setting, Plus, ArrowRightBold } from '@element-plus/icons-vue'
|
||||
import ScriptEdit from '@/views/scripts/components/script-edit.vue'
|
||||
import ScriptGroup from '@/views/scripts/components/script-group.vue'
|
||||
import PlusLimitTip from '@/components/common/PlusLimitTip.vue'
|
||||
|
||||
defineProps({
|
||||
hostId: {
|
||||
@@ -242,46 +237,6 @@ if (scriptGroupList.value.length) {
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
.plus_tips {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.plus_tips_content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
|
||||
.lock-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.plus_label {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
.to_active {
|
||||
color: #ffd700;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.left_box {
|
||||
width: 50%;
|
||||
min-width: 50%;
|
||||
|
||||
@@ -231,7 +231,7 @@ const connectSftp = () => {
|
||||
socket.value.on('connect', () => {
|
||||
console.log('/sftp socket已连接:', socket.value.id)
|
||||
listenSftp()
|
||||
socket.value.emit('create', { hostId: props.hostId, token: token.value })
|
||||
socket.value.emit('ws_sftp', { hostId: props.hostId, token: token.value })
|
||||
socket.value.on('root_ls', (tree) => {
|
||||
let temp = sortDirTree(tree).filter((item) => isDir(item.type))
|
||||
temp.unshift({ name: '/', type: 'd' })
|
||||
|
||||
@@ -132,13 +132,13 @@ const connectIO = () => {
|
||||
console.log('/terminal socket已连接:', hostId.value)
|
||||
|
||||
socketConnected.value = true
|
||||
socket.value.emit('create', { hostId: hostId.value, token: token.value })
|
||||
socket.value.on('connect_terminal_success', () => {
|
||||
socket.value.emit('ws_terminal', { hostId: hostId.value, token: token.value })
|
||||
socket.value.on('terminal_connect_success', () => {
|
||||
socket.value.on('output', (str) => {
|
||||
term.value.write(str)
|
||||
terminalText.value += str
|
||||
})
|
||||
socket.value.on('connect_shell_success', () => {
|
||||
socket.value.on('terminal_connect_shell_success', () => {
|
||||
curStatus.value = CONNECT_SUCCESS
|
||||
shellResize()
|
||||
if (initCommand.value) socket.value.emit('input', initCommand.value + '\n')
|
||||
@@ -168,17 +168,17 @@ const connectIO = () => {
|
||||
term.value.write(`${ msg }\r\n`)
|
||||
})
|
||||
|
||||
socket.value.on('connect_close', () => {
|
||||
socket.value.on('terminal_connect_close', () => {
|
||||
curStatus.value = CONNECT_FAIL
|
||||
term.value.write('\r\n\x1b[91m终端主动断开连接, 回车重新发起连接\x1b[0m')
|
||||
})
|
||||
|
||||
socket.value.on('connect_terminal_fail', (message) => {
|
||||
socket.value.on('terminal_connect_fail', (message) => {
|
||||
curStatus.value = CONNECT_FAIL
|
||||
term.value.write(`\r\n\x1b[91m连接终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
|
||||
})
|
||||
|
||||
socket.value.on('create_terminal_fail', (message) => {
|
||||
socket.value.on('terminal_create_fail', (message) => {
|
||||
curStatus.value = CONNECT_FAIL
|
||||
term.value.write(`\r\n\x1b[91m创建终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
|
||||
})
|
||||
@@ -481,11 +481,11 @@ const handleRightClick = async (e) => {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: `日志: docker logs -f ${ str }`,
|
||||
label: `停止: docker stop ${ str }`,
|
||||
onClick: () => {
|
||||
if (!plusTips()) return
|
||||
focusTab()
|
||||
inputCommand(`docker logs -f ${ str }`)
|
||||
inputCommand(`docker stop ${ str }`)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -511,6 +511,14 @@ const handleRightClick = async (e) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: `日志: docker logs -f ${ str }`,
|
||||
onClick: () => {
|
||||
if (!plusTips()) return
|
||||
focusTab()
|
||||
inputCommand(`docker logs -f ${ str }`)
|
||||
}
|
||||
},
|
||||
]
|
||||
} : null
|
||||
|
||||
@@ -614,6 +622,15 @@ const inputCommand = (command, isSyncAllSession = false) => {
|
||||
socket.value.emit('input', command)
|
||||
}
|
||||
|
||||
EventBus.$on('exec_external_command', (command) => {
|
||||
if (!socket.value || !socket.value.connected || curStatus.value !== CONNECT_SUCCESS) {
|
||||
$message.error('终端连接已断开,无法执行指令')
|
||||
return
|
||||
}
|
||||
socket.value.emit('input', command + '\n')
|
||||
term.value?.focus()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
createLocalTerminal()
|
||||
await getCommand()
|
||||
|
||||
@@ -128,8 +128,8 @@
|
||||
--el-switch-on-color: #13ce66;
|
||||
--el-switch-off-color: #ff4949;
|
||||
"
|
||||
active-text="工具栏"
|
||||
inactive-text="工具栏"
|
||||
active-text="功能栏"
|
||||
inactive-text="功能栏"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user