2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 01:57:19 +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

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/bddjr/hlfhr v1.4.0
github.com/beevik/ntp v1.5.0
github.com/coder/websocket v1.8.14
github.com/containerd/errdefs v1.0.0
github.com/coreos/go-systemd/v22 v22.6.0
github.com/creack/pty v1.1.24
github.com/dchest/captcha v1.1.0
@@ -64,7 +65,6 @@ require (
github.com/G-Core/gcore-dns-sdk-go v0.3.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect

View File

@@ -18,8 +18,9 @@ func NewWs(ws *service.WsService) *Ws {
func (route *Ws) Register(r *chi.Mux) {
r.Route("/api/ws", func(r chi.Router) {
r.Get("/ssh", route.ws.Session)
r.Get("/exec", route.ws.Exec)
r.Get("/pty", route.ws.PTY)
r.Get("/ssh", route.ws.Session)
r.Get("/container/{id}", route.ws.ContainerTerminal)
r.Get("/container/image/pull", route.ws.ContainerImagePull)
})

View File

@@ -38,49 +38,6 @@ func NewWsService(t *gotext.Locale, conf *config.Config, log *slog.Logger, ssh b
}
}
func (s *WsService) Session(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
info, err := s.sshRepo.Get(req.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("[Websocket] upgrade session ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
client, err := ssh.NewSSHClient(info.Config)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
defer func(client *stdssh.Client) { _ = client.Close() }(client)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
turn, err := ssh.NewTurn(ctx, ws, client)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
go func() {
defer turn.Close() // Handle 退出后关闭 SSH 连接,以结束 Wait 阶段
_ = turn.Handle(ctx)
}()
turn.Wait()
}
func (s *WsService) Exec(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrade(w, r)
if err != nil {
@@ -90,7 +47,7 @@ func (s *WsService) Exec(w http.ResponseWriter, r *http.Request) {
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
// 第一条消息是命令
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
_, cmd, err := ws.Read(ctx)
@@ -119,7 +76,91 @@ func (s *WsService) Exec(w http.ResponseWriter, r *http.Request) {
s.readLoop(ctx, ws)
}
// ContainerTerminal 容器终端 WebSocket 处理
// PTY 通用 PTY 命令执行
// 前端发送第一条消息为要执行的命令,后端通过 PTY 执行并实时返回输出
func (s *WsService) PTY(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("[Websocket] upgrade pty ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// 要执行的命令
_, message, err := ws.Read(ctx)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read command: %v", err))
return
}
command := string(message)
if command == "" {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("command is empty"))
return
}
// PTY 执行命令
turn, err := shell.NewPTYTurn(ctx, ws, command)
if err != nil {
_ = ws.Write(ctx, websocket.MessageBinary, []byte("\r\n"+s.t.Get("Failed to start command: %v", err)+"\r\n"))
_ = ws.Close(websocket.StatusNormalClosure, "")
return
}
go func() {
defer turn.Close()
_ = turn.Handle(ctx)
}()
turn.Wait()
}
func (s *WsService) Session(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
info, err := s.sshRepo.Get(req.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("[Websocket] upgrade session ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
sshClient, err := ssh.NewSSHClient(info.Config)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
defer func(sshClient *stdssh.Client) { _ = sshClient.Close() }(sshClient)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
turn, err := ssh.NewTurn(ctx, ws, sshClient)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
go func() {
defer turn.Close() // Handle 退出后关闭 SSH 连接,以结束 Wait 阶段
_ = turn.Handle(ctx)
}()
turn.Wait()
}
// ContainerTerminal 容器终端
func (s *WsService) ContainerTerminal(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ContainerID](r)
if err != nil {
@@ -134,7 +175,7 @@ func (s *WsService) ContainerTerminal(w http.ResponseWriter, r *http.Request) {
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// 默认使用 bash 作为 shell如果不存在则回退到 sh
@@ -155,30 +196,7 @@ func (s *WsService) ContainerTerminal(w http.ResponseWriter, r *http.Request) {
turn.Wait()
}
func (s *WsService) upgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
opts := &websocket.AcceptOptions{
CompressionMode: websocket.CompressionContextTakeover,
}
// debug 模式下不校验 origin方便 vite 代理调试
if s.conf.App.Debug {
opts.InsecureSkipVerify = true
}
return websocket.Accept(w, r, opts)
}
// readLoop 阻塞直到客户端关闭连接
func (s *WsService) readLoop(ctx context.Context, c *websocket.Conn) {
for {
if _, _, err := c.Read(ctx); err != nil {
_ = c.CloseNow()
break
}
}
}
// ContainerImagePull 镜像拉取 WebSocket 处理
// ContainerImagePull 镜像拉取
func (s *WsService) ContainerImagePull(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrade(w, r)
if err != nil {
@@ -187,7 +205,7 @@ func (s *WsService) ContainerImagePull(w http.ResponseWriter, r *http.Request) {
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
_, message, err := ws.Read(ctx)
@@ -269,3 +287,26 @@ func (s *WsService) ContainerImagePull(w http.ResponseWriter, r *http.Request) {
_ = ws.Write(ctx, websocket.MessageText, completeMsg)
_ = ws.Close(websocket.StatusNormalClosure, "")
}
func (s *WsService) upgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
opts := &websocket.AcceptOptions{
CompressionMode: websocket.CompressionContextTakeover,
}
// debug 模式下不校验 origin方便 vite 代理调试
if s.conf.App.Debug {
opts.InsecureSkipVerify = true
}
return websocket.Accept(w, r, opts)
}
// readLoop 阻塞直到客户端关闭连接
func (s *WsService) readLoop(ctx context.Context, c *websocket.Conn) {
for {
if _, _, err := c.Read(ctx); err != nil {
_ = c.CloseNow()
break
}
}
}

View File

@@ -10,7 +10,6 @@ import (
"os/exec"
"slices"
"strings"
"syscall"
"time"
"github.com/creack/pty"
@@ -208,7 +207,7 @@ func ExecfWithTTY(shell string, args ...any) (string, error) {
}
defer func(f *os.File) { _ = f.Close() }(f)
if _, err = io.Copy(&out, f); ptyError(err) != nil {
if _, err = io.Copy(&out, f); IsPTYError(err) != nil {
return "", fmt.Errorf("run %s failed, out: %s, err: %w", shell, strings.TrimSpace(out.String()), err)
}
if stderr.Len() > 0 {
@@ -228,15 +227,3 @@ func preCheckArg(args []any) bool {
return true
}
// Linux kernel return EIO when attempting to read from a master pseudo
// terminal which no longer has an open slave. So ignore error here.
// See https://github.com/creack/pty/issues/21
func ptyError(err error) error {
var pathErr *os.PathError
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, syscall.EIO) {
return err
}
return nil
}

149
pkg/shell/pty.go Normal file
View File

@@ -0,0 +1,149 @@
package shell
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"syscall"
"github.com/coder/websocket"
"github.com/creack/pty"
)
// MessageResize 终端大小调整消息
type MessageResize struct {
Resize bool `json:"resize"`
Columns uint `json:"columns"`
Rows uint `json:"rows"`
}
// Turn PTY 终端
type Turn struct {
ctx context.Context
ws *websocket.Conn
ptmx *os.File
cmd *exec.Cmd
}
// NewPTYTurn 使用 PTY 执行命令,返回 Turn 用于流式读取输出
// 调用方需要负责调用 Close() 和 Wait()
func NewPTYTurn(ctx context.Context, ws *websocket.Conn, shell string, args ...any) (*Turn, error) {
if !preCheckArg(args) {
return nil, errors.New("command contains illegal characters")
}
if len(args) > 0 {
shell = fmt.Sprintf(shell, args...)
}
_ = os.Setenv("LC_ALL", "C")
cmd := exec.CommandContext(ctx, "bash", "-c", shell)
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, fmt.Errorf("failed to start pty: %w", err)
}
return &Turn{
ctx: ctx,
ws: ws,
ptmx: ptmx,
cmd: cmd,
}, nil
}
// Write 写入 PTY 输入
func (t *Turn) Write(data []byte) (int, error) {
return t.ptmx.Write(data)
}
// Wait 等待命令完成
func (t *Turn) Wait() {
_ = t.cmd.Wait()
}
// Close 关闭 PTY
func (t *Turn) Close() {
_ = t.ptmx.Close()
}
// Handle 从 WebSocket 读取输入写入 PTY
func (t *Turn) Handle(ctx context.Context) error {
var resize MessageResize
go func() { _ = t.Pipe(ctx) }()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
_, data, err := t.ws.Read(ctx)
if err != nil {
// 通常是客户端关闭连接
return fmt.Errorf("failed to read ws message: %w", err)
}
// 判断是否是 resize 消息
if err = json.Unmarshal(data, &resize); err == nil {
if resize.Resize && resize.Columns > 0 && resize.Rows > 0 {
if err = t.Resize(uint16(resize.Rows), uint16(resize.Columns)); err != nil {
return fmt.Errorf("failed to resize terminal: %w", err)
}
}
continue
}
if _, err = t.Write(data); err != nil {
return fmt.Errorf("failed to write to pty stdin: %w", err)
}
}
}
}
// Pipe 从 PTY 读取输出写入 WebSocket
func (t *Turn) Pipe(ctx context.Context) error {
buf := make([]byte, 8192)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
n, err := t.ptmx.Read(buf)
if err != nil {
if err = IsPTYError(err); err != nil {
return fmt.Errorf("failed to read from pty: %w", err)
}
return nil
}
if n > 0 {
if err = t.ws.Write(ctx, websocket.MessageBinary, buf[:n]); err != nil {
return fmt.Errorf("failed to write to ws: %w", err)
}
}
}
}
}
// Resize 调整 PTY 窗口大小
func (t *Turn) Resize(rows, cols uint16) error {
return pty.Setsize(t.ptmx, &pty.Winsize{
Rows: rows,
Cols: cols,
})
}
// IsPTYError Linux kernel return EIO when attempting to read from a master pseudo
// terminal which no longer has an open slave. So ignore error here.
// See https://github.com/creack/pty/issues/21
func IsPTYError(err error) error {
var pathErr *os.PathError
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, syscall.EIO) || !errors.Is(err, io.EOF) {
return err
}
return nil
}

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()
}