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:
2
go.mod
2
go.mod
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
149
pkg/shell/pty.go
Normal 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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
303
web/src/components/common/PtyTerminalModal.vue
Normal file
303
web/src/components/common/PtyTerminalModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user