2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-07 18:13:13 +08:00

feat: 添加容器编排实时进度显示功能 (#1217)

* Initial plan

* feat: 添加容器编排实时进度显示功能

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 修复代码审查问题,使用动态路径和安全的字体大小操作

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* docs: 完成容器编排实时进度显示功能

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 容器优化

* fix: 用户关闭命令窗口时杀死正在运行的进程

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 改进 WebSocket 断开检测,支持用户输入转发到 PTY

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 启用终端用户输入支持,转发键盘输入到 PTY

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 容器优化

* fix: lint

* feat: 添加 PTY 窗口大小调整支持

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 支持读取输入

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
Copilot
2026-01-11 18:37:01 +08:00
committed by GitHub
parent b5203b194a
commit 8031e53852
10 changed files with 724 additions and 152 deletions

View File

@@ -13,6 +13,17 @@ export default {
ws.onerror = (e) => reject(e)
})
},
// PTY 命令执行
pty: (command: string): Promise<WebSocket> => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${base}/pty`)
ws.onopen = () => {
ws.send(command)
resolve(ws)
}
ws.onerror = (e) => reject(e)
})
},
// 连接SSH
ssh: (id: number): Promise<WebSocket> => {
return new Promise((resolve, reject) => {

View File

@@ -0,0 +1,303 @@
<script setup lang="ts">
import '@fontsource-variable/jetbrains-mono/wght-italic.css'
import '@fontsource-variable/jetbrains-mono/wght.css'
import { ClipboardAddon } from '@xterm/addon-clipboard'
import { FitAddon } from '@xterm/addon-fit'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { useGettext } from 'vue3-gettext'
import ws from '@/api/ws'
const { $gettext } = useGettext()
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const props = defineProps({
title: {
type: String,
default: ''
},
command: {
type: String,
required: true
}
})
const emit = defineEmits<{
(e: 'complete'): void
(e: 'error', error: string): void
}>()
const isRunning = ref(false)
const terminalRef = ref<HTMLElement | null>(null)
const term = ref<Terminal | null>(null)
let ptyWs: WebSocket | null = null
let fitAddon: FitAddon | null = null
let webglAddon: WebglAddon | null = null
// 初始化终端
const initTerminal = async () => {
if (!terminalRef.value || !props.command) {
return
}
isRunning.value = true
try {
ptyWs = await ws.pty(props.command)
ptyWs.binaryType = 'arraybuffer'
term.value = new Terminal({
allowProposedApi: true,
lineHeight: 1.2,
fontSize: 14,
fontFamily: `'JetBrains Mono Variable', monospace`,
cursorBlink: true,
cursorStyle: 'underline',
tabStopWidth: 4,
disableStdin: false,
convertEol: true,
theme: { background: '#111', foreground: '#fff' }
})
fitAddon = new FitAddon()
webglAddon = new WebglAddon()
term.value.loadAddon(fitAddon)
term.value.loadAddon(new ClipboardAddon())
term.value.loadAddon(new WebLinksAddon())
term.value.loadAddon(new Unicode11Addon())
term.value.unicode.activeVersion = '11'
term.value.loadAddon(webglAddon)
webglAddon.onContextLoss(() => {
webglAddon?.dispose()
})
term.value.open(terminalRef.value)
ptyWs.onmessage = (ev) => {
const data: ArrayBuffer | string = ev.data
term.value?.write(typeof data === 'string' ? data : new Uint8Array(data))
}
term.value?.onData((data) => {
if (ptyWs?.readyState === WebSocket.OPEN) {
ptyWs?.send(data)
}
})
term.value?.onBinary((data) => {
if (ptyWs?.readyState === WebSocket.OPEN) {
const buffer = new Uint8Array(data.length)
for (let i = 0; i < data.length; ++i) {
buffer[i] = data.charCodeAt(i) & 255
}
ptyWs?.send(buffer)
}
})
term.value.onResize(({ rows, cols }) => {
if (ptyWs && ptyWs.readyState === WebSocket.OPEN) {
ptyWs.send(
JSON.stringify({
resize: true,
columns: cols,
rows: rows
})
)
}
})
fitAddon.fit()
term.value.focus()
window.addEventListener('resize', onTerminalResize, false)
ptyWs.onclose = () => {
isRunning.value = false
if (term.value) {
term.value.write('\r\n' + $gettext('Connection closed.'))
}
window.removeEventListener('resize', onTerminalResize)
emit('complete')
}
ptyWs.onerror = (event) => {
isRunning.value = false
if (term.value) {
term.value.write('\r\n' + $gettext('Connection error.'))
}
console.error(event)
ptyWs?.close()
emit('error', $gettext('Connection error'))
}
} catch (error) {
console.error('Failed to start PTY:', error)
isRunning.value = false
emit('error', $gettext('Failed to connect'))
}
}
// 关闭终端
const closeTerminal = () => {
try {
if (ptyWs) {
ptyWs.close()
ptyWs = null
}
if (term.value) {
term.value.dispose()
term.value = null
}
fitAddon = null
webglAddon = null
if (terminalRef.value) {
terminalRef.value.innerHTML = ''
}
window.removeEventListener('resize', onTerminalResize)
} catch {
/* empty */
}
}
// 处理窗口大小变化
const onTerminalResize = () => {
if (fitAddon && term.value) {
fitAddon.fit()
}
}
// 终端滚轮缩放
const onTerminalWheel = (event: WheelEvent) => {
if (event.ctrlKey && term.value && fitAddon) {
event.preventDefault()
if (event.deltaY > 0) {
if (term.value.options.fontSize! > 12) {
term.value.options.fontSize = term.value.options.fontSize! - 1
}
} else {
term.value.options.fontSize = term.value.options.fontSize! + 1
}
fitAddon.fit()
}
}
// 模态框关闭后清理
const handleModalClose = () => {
closeTerminal()
isRunning.value = false
}
// 处理关闭前确认
const handleBeforeClose = (): Promise<boolean> => {
return new Promise((resolve) => {
if (isRunning.value) {
window.$dialog.warning({
title: $gettext('Confirm'),
content: $gettext(
'Command is still running. Closing the window will terminate the command. Are you sure?'
),
positiveText: $gettext('Confirm'),
negativeText: $gettext('Cancel'),
onPositiveClick: () => {
resolve(true)
},
onNegativeClick: () => {
resolve(false)
},
onClose: () => {
resolve(false)
},
onMaskClick: () => {
resolve(false)
}
})
} else {
resolve(true)
}
})
}
// 处理遮罩点击
const handleMaskClick = async () => {
if (await handleBeforeClose()) {
show.value = false
}
}
// 监听 show 变化,自动初始化终端
watch(
() => show.value,
async (newVal) => {
if (newVal) {
await nextTick()
await initTerminal()
}
}
)
onUnmounted(() => {
closeTerminal()
})
defineExpose({
initTerminal,
closeTerminal
})
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="title || $gettext('Terminal')"
style="width: 90vw; height: 80vh"
size="huge"
:bordered="false"
:segmented="false"
:mask-closable="false"
:closable="true"
:on-close="handleBeforeClose"
@mask-click="handleMaskClick"
@after-leave="handleModalClose"
>
<div
ref="terminalRef"
@wheel="onTerminalWheel"
style="height: 100%; min-height: 60vh; background: #111"
></div>
</n-modal>
</template>
<style scoped lang="scss">
:deep(.xterm) {
padding: 1rem !important;
}
:deep(.xterm .xterm-viewport::-webkit-scrollbar) {
border-radius: 0.4rem;
height: 6px;
width: 8px;
}
:deep(.xterm .xterm-viewport::-webkit-scrollbar-thumb) {
background-color: #666;
border-radius: 0.4rem;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
transition: all 1s;
}
:deep(.xterm .xterm-viewport:hover::-webkit-scrollbar-thumb) {
background-color: #aaa;
}
:deep(.xterm .xterm-viewport::-webkit-scrollbar-track) {
background-color: #111;
border-radius: 0.4rem;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
transition: all 1s;
}
:deep(.xterm .xterm-viewport:hover::-webkit-scrollbar-track) {
background-color: #444;
}
</style>

View File

@@ -3,6 +3,7 @@ import { NButton, NCheckbox, NDataTable, NFlex, NInput, NPopconfirm, NTag } from
import { useGettext } from 'vue3-gettext'
import container from '@/api/panel/container'
import PtyTerminalModal from '@/components/common/PtyTerminalModal.vue'
import { useFileStore } from '@/store'
import { formatDateTime } from '@/utils'
@@ -28,6 +29,28 @@ const updateModel = ref({
})
const updateModal = ref(false)
// Compose 启动状态
const upModal = ref(false)
const upComposeName = ref('')
const upCommand = ref('')
// 处理 Compose 启动
const handleComposeUp = (row: any, force: boolean) => {
upComposeName.value = row.name
let cmd = `docker compose -f ${row.path}/docker-compose.yml up -d`
if (force) {
cmd += ' --pull always'
}
upCommand.value = cmd
upModal.value = true
}
// Compose 启动完成
const handleUpComplete = () => {
refresh()
forcePull.value = false
}
const columns: any = [
{ type: 'selection', fixed: 'left' },
{
@@ -104,18 +127,7 @@ const columns: any = [
{
showIcon: false,
onPositiveClick: () => {
const messageReactive = window.$message.loading($gettext('Starting...'), {
duration: 0
})
useRequest(container.composeUp(row.name, forcePull.value))
.onSuccess(() => {
refresh()
forcePull.value = false
window.$message.success($gettext('Start successful'))
})
.onComplete(() => {
messageReactive?.destroy()
})
handleComposeUp(row, forcePull.value)
}
},
{
@@ -391,4 +403,10 @@ onMounted(() => {
{{ $gettext('Submit') }}
</n-button>
</n-modal>
<pty-terminal-modal
v-model:show="upModal"
:title="$gettext('Starting Compose') + ' - ' + upComposeName"
:command="upCommand"
@complete="handleUpComplete"
/>
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import '@fontsource-variable/jetbrains-mono/wght-italic.css'
import '@fontsource-variable/jetbrains-mono/wght.css'
import { AttachAddon } from '@xterm/addon-attach'
import { ClipboardAddon } from '@xterm/addon-clipboard'
import { FitAddon } from '@xterm/addon-fit'
import { Unicode11Addon } from '@xterm/addon-unicode11'
@@ -32,8 +31,8 @@ const terminalContainerName = ref('')
const terminalRef = ref<HTMLElement | null>(null)
const term = ref<Terminal | null>(null)
let containerWs: WebSocket | null = null
const fitAddon = new FitAddon()
const webglAddon = new WebglAddon()
let fitAddon: FitAddon | null = null
let webglAddon: WebglAddon | null = null
const containerCreateModal = ref(false)
const selectedRowKeys = ref<any>([])
@@ -405,6 +404,7 @@ const handleOpenTerminal = async (row: any) => {
try {
containerWs = await ws.container(row.id)
containerWs.binaryType = 'arraybuffer'
term.value = new Terminal({
allowProposedApi: true,
@@ -417,7 +417,9 @@ const handleOpenTerminal = async (row: any) => {
theme: { background: '#111', foreground: '#fff' }
})
term.value.loadAddon(new AttachAddon(containerWs))
fitAddon = new FitAddon()
webglAddon = new WebglAddon()
term.value.loadAddon(fitAddon)
term.value.loadAddon(new ClipboardAddon())
term.value.loadAddon(new WebLinksAddon())
@@ -425,11 +427,41 @@ const handleOpenTerminal = async (row: any) => {
term.value.unicode.activeVersion = '11'
term.value.loadAddon(webglAddon)
webglAddon.onContextLoss(() => {
webglAddon.dispose()
webglAddon?.dispose()
})
term.value.open(terminalRef.value)
onTerminalResize()
containerWs.onmessage = (ev) => {
const data: ArrayBuffer | string = ev.data
term.value?.write(typeof data === 'string' ? data : new Uint8Array(data))
}
term.value.onData((data) => {
if (containerWs?.readyState === WebSocket.OPEN) {
containerWs?.send(data)
}
})
term.value.onBinary((data) => {
if (containerWs?.readyState === WebSocket.OPEN) {
const buffer = new Uint8Array(data.length)
for (let i = 0; i < data.length; ++i) {
buffer[i] = data.charCodeAt(i) & 255
}
containerWs?.send(buffer)
}
})
term.value.onResize(({ rows, cols }) => {
if (containerWs && containerWs.readyState === WebSocket.OPEN) {
containerWs.send(
JSON.stringify({
resize: true,
columns: cols,
rows: rows
})
)
}
})
fitAddon.fit()
term.value.focus()
window.addEventListener('resize', onTerminalResize, false)
@@ -457,14 +489,16 @@ const handleOpenTerminal = async (row: any) => {
// 关闭容器终端
const closeTerminal = () => {
try {
if (term.value) {
term.value.dispose()
term.value = null
}
if (containerWs) {
containerWs.close()
containerWs = null
}
if (term.value) {
term.value.dispose()
term.value = null
}
fitAddon = null
webglAddon = null
if (terminalRef.value) {
terminalRef.value.innerHTML = ''
}
@@ -476,22 +510,14 @@ const closeTerminal = () => {
// 终端大小调整
const onTerminalResize = () => {
fitAddon.fit()
if (containerWs != null && containerWs.readyState === 1 && term.value) {
const { cols, rows } = term.value
containerWs.send(
JSON.stringify({
resize: true,
columns: cols,
rows: rows
})
)
if (fitAddon && term.value) {
fitAddon.fit()
}
}
// 终端滚轮缩放
const onTerminalWheel = (event: WheelEvent) => {
if (event.ctrlKey && term.value) {
if (event.ctrlKey && term.value && fitAddon) {
event.preventDefault()
if (event.deltaY > 0) {
if (term.value.options.fontSize! > 12) {

View File

@@ -9,7 +9,6 @@ import CreateModal from '@/views/ssh/CreateModal.vue'
import UpdateModal from '@/views/ssh/UpdateModal.vue'
import '@fontsource-variable/jetbrains-mono/wght-italic.css'
import '@fontsource-variable/jetbrains-mono/wght.css'
import { AttachAddon } from '@xterm/addon-attach'
import { ClipboardAddon } from '@xterm/addon-clipboard'
import { FitAddon } from '@xterm/addon-fit'
import { Unicode11Addon } from '@xterm/addon-unicode11'
@@ -22,10 +21,10 @@ import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const terminal = ref<HTMLElement | null>(null)
const term = ref()
const term = ref<Terminal | null>(null)
let sshWs: WebSocket | null = null
const fitAddon = new FitAddon()
const webglAddon = new WebglAddon()
let fitAddon: FitAddon | null = null
let webglAddon: WebglAddon | null = null
const current = ref(0)
const collapsed = ref(true)
@@ -112,7 +111,7 @@ const handleDelete = (id: number) => {
if (list.value.length > 0) {
openSession(Number(list.value[0].key))
} else {
term.value.dispose()
term.value?.dispose()
}
if (list.value.length === 0) {
create.value = true
@@ -127,8 +126,10 @@ const handleChange = (key: number) => {
const openSession = async (id: number) => {
closeSession()
await ws.ssh(id).then((ws) => {
sshWs = ws
await ws.ssh(id).then((socket) => {
sshWs = socket
sshWs.binaryType = 'arraybuffer'
term.value = new Terminal({
allowProposedApi: true,
lineHeight: 1.2,
@@ -140,7 +141,9 @@ const openSession = async (id: number) => {
theme: { background: '#111', foreground: '#fff' }
})
term.value.loadAddon(new AttachAddon(ws))
fitAddon = new FitAddon()
webglAddon = new WebglAddon()
term.value.loadAddon(fitAddon)
term.value.loadAddon(new ClipboardAddon())
term.value.loadAddon(new WebLinksAddon())
@@ -148,61 +151,94 @@ const openSession = async (id: number) => {
term.value.unicode.activeVersion = '11'
term.value.loadAddon(webglAddon)
webglAddon.onContextLoss(() => {
webglAddon.dispose()
webglAddon?.dispose()
})
term.value.open(terminal.value!)
onResize()
sshWs.onmessage = (ev) => {
const data: ArrayBuffer | string = ev.data
term.value?.write(typeof data === 'string' ? data : new Uint8Array(data))
}
term.value?.onData((data) => {
if (sshWs?.readyState === WebSocket.OPEN) {
sshWs?.send(data)
}
})
term.value?.onBinary((data) => {
if (sshWs?.readyState === WebSocket.OPEN) {
const buffer = new Uint8Array(data.length)
for (let i = 0; i < data.length; ++i) {
buffer[i] = data.charCodeAt(i) & 255
}
sshWs?.send(buffer)
}
})
term.value.onResize(({ rows, cols }) => {
if (sshWs?.readyState === WebSocket.OPEN) {
sshWs?.send(
JSON.stringify({
resize: true,
columns: cols,
rows: rows
})
)
}
})
fitAddon.fit()
term.value.focus()
window.addEventListener('resize', onResize, false)
current.value = id
ws.onclose = () => {
term.value.write('\r\n' + $gettext('Connection closed. Please refresh.'))
sshWs.onclose = () => {
term.value?.write('\r\n' + $gettext('Connection closed. Please refresh.'))
window.removeEventListener('resize', onResize)
}
ws.onerror = (event) => {
term.value.write('\r\n' + $gettext('Connection error. Please refresh.'))
sshWs.onerror = (event) => {
term.value?.write('\r\n' + $gettext('Connection error. Please refresh.'))
console.error(event)
ws.close()
sshWs?.close()
}
})
}
const closeSession = () => {
try {
term.value.dispose()
sshWs?.close()
terminal.value!.innerHTML = ''
if (sshWs) {
sshWs.close()
sshWs = null
}
if (term.value) {
term.value.dispose()
term.value = null
}
fitAddon = null
webglAddon = null
if (terminal.value) {
terminal.value.innerHTML = ''
}
} catch {
/* empty */
}
}
const onResize = () => {
fitAddon.fit()
if (sshWs != null && sshWs.readyState === 1) {
const { cols, rows } = term.value
sshWs.send(
JSON.stringify({
resize: true,
columns: cols,
rows: rows
})
)
if (fitAddon && term.value) {
fitAddon.fit()
}
}
const onTermWheel = (event: WheelEvent) => {
if (event.ctrlKey) {
if (event.ctrlKey && term.value && fitAddon) {
event.preventDefault()
const fontSize = term.value.options.fontSize ?? 14
if (event.deltaY > 0) {
if (term.value.options.fontSize > 12) {
term.value.options.fontSize = term.value.options.fontSize - 1
if (fontSize > 12) {
term.value.options.fontSize = fontSize - 1
}
} else {
term.value.options.fontSize = term.value.options.fontSize + 1
term.value.options.fontSize = fontSize + 1
}
fitAddon.fit()
}