2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 05:31:44 +08:00
Files
panel/internal/service/ws.go
Copilot 8031e53852 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>
2026-01-11 18:37:01 +08:00

313 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"github.com/coder/websocket"
"github.com/leonelquinteros/gotext"
"github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
stdssh "golang.org/x/crypto/ssh"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/config"
"github.com/acepanel/panel/pkg/docker"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/ssh"
)
type WsService struct {
t *gotext.Locale
conf *config.Config
log *slog.Logger
sshRepo biz.SSHRepo
}
func NewWsService(t *gotext.Locale, conf *config.Config, log *slog.Logger, ssh biz.SSHRepo) *WsService {
return &WsService{
t: t,
conf: conf,
log: log,
sshRepo: ssh,
}
}
func (s *WsService) Exec(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("[Websocket] upgrade exec ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
// 第一条消息是命令
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
_, cmd, err := ws.Read(ctx)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read command: %v", err))
return
}
out, err := shell.ExecfWithPipe(ctx, string(cmd))
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to run command: %v", err))
return
}
go func() {
scanner := bufio.NewScanner(out)
for scanner.Scan() {
line := scanner.Text()
_ = ws.Write(ctx, websocket.MessageText, []byte(line))
}
if err = scanner.Err(); err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read command output: %v", err))
}
}()
s.readLoop(ctx, ws)
}
// 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 {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("[Websocket] upgrade container terminal ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// 默认使用 bash 作为 shell如果不存在则回退到 sh
turn, err := docker.NewTurn(ctx, ws, req.ID, []string{"/bin/bash"})
if err != nil {
turn, err = docker.NewTurn(ctx, ws, req.ID, []string{"/bin/sh"})
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to start container terminal: %v", err))
return
}
}
go func() {
defer turn.Close()
_ = turn.Handle(ctx)
}()
turn.Wait()
}
// ContainerImagePull 镜像拉取
func (s *WsService) ContainerImagePull(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("[Websocket] upgrade image pull 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 params: %v", err))
return
}
var req request.ContainerImagePull
if err = json.Unmarshal(message, &req); err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("invalid params: %v", err))
return
}
// 创建 Docker 客户端
apiClient, err := client.New(client.WithHost("unix:///var/run/docker.sock"))
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to create docker client: %v", err))
return
}
defer func(apiClient *client.Client) { _ = apiClient.Close() }(apiClient)
// 构建拉取选项
options := client.ImagePullOptions{}
if req.Auth {
authConfig := registry.AuthConfig{
Username: req.Username,
Password: req.Password,
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to encode auth: %v", err))
return
}
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
}
// 拉取镜像
resp, err := apiClient.ImagePull(ctx, req.Name, options)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to pull image: %v", err))
return
}
// 迭代进度
for msg, err := range resp.JSONMessages(ctx) {
if err != nil {
s.log.Warn("[Websocket] image pull error", slog.Any("err", err))
errorMsg, _ := json.Marshal(map[string]any{
"status": "error",
"error": err.Error(),
})
_ = ws.Write(ctx, websocket.MessageText, errorMsg)
return
}
// 如果有错误,发送错误消息
if msg.Error != nil {
errorMsg, _ := json.Marshal(map[string]any{
"status": "error",
"error": msg.Error.Message,
})
_ = ws.Write(ctx, websocket.MessageText, errorMsg)
return
}
// 转发进度信息
progressMsg, _ := json.Marshal(msg)
if err = ws.Write(ctx, websocket.MessageText, progressMsg); err != nil {
s.log.Warn("[Websocket] write image pull progress error", slog.Any("err", err))
return
}
}
// 拉取完成
completeMsg, _ := json.Marshal(map[string]any{
"status": "complete",
"complete": true,
})
_ = 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
}
}
}