feat: 功能栏新增docker容器视图

This commit is contained in:
chaos-zhu
2025-05-24 21:15:52 +08:00
parent bcac3075b0
commit 9b14743b6d
20 changed files with 705 additions and 88 deletions
+1
View File
@@ -16,3 +16,4 @@ plan.md
local-script
版本发布.md
.vscode
.windsurfrules
File diff suppressed because one or more lines are too long
+2
View File
@@ -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上
+72
View File
@@ -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 }`)
})
})
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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) {
+19 -13
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "3.0.6",
"version": "3.0.7",
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.0.6",
"version": "3.0.7",
"description": "easynode-web",
"private": true,
"scripts": {
+18 -8
View File
@@ -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>
+1 -1
View File
@@ -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%;
+1 -1
View File
@@ -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>