diff --git a/agent/app/api/v2/terminal.go b/agent/app/api/v2/terminal.go index 0260cb1f1242..4c25caccc39b 100644 --- a/agent/app/api/v2/terminal.go +++ b/agent/app/api/v2/terminal.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" @@ -19,11 +20,13 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/pkg/errors" + gossh "golang.org/x/crypto/ssh" ) // @Tags Terminal // @Summary Ws local terminal // @Param command query string false "command" +// @Param session query string false "session id to reattach" // @Success 200 // @Security ApiKeyAuth // @Security Timestamp @@ -36,6 +39,8 @@ func (b *BaseApi) WsLocalTerminal(c *gin.Context) { // @Summary Ws host SSH // @Param id query integer false "id" // @Param command query string false "command" +// @Param session query string false "session id to reattach" +// @Param title query string false "session title shown in the session list" // @Success 200 // @Security ApiKeyAuth // @Security Timestamp @@ -122,25 +127,75 @@ func (b *BaseApi) runSSHSession(c *gin.Context, connect func() (*ssh.SSHClient, } defer wsConn.Close() - client, clientErr := connect() - if wshandleError(wsConn, errors.WithMessage(clientErr, "failed to set up the connection. Please check the host information")) { - return + hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0")) + opts := terminal.SessionOptions{ + Owner: loadAuditUser(c), + Title: sanitizeTerminalTitle(c.Query("title")), + HostID: uint(max(hostID, 0)), + Cols: cols, + Rows: rows, + InitCmd: command, + } + err := terminal.Serve(wsConn, strings.TrimSpace(c.Query("session")), opts, func() (*gossh.Client, error) { + client, err := connect() + if err != nil { + return nil, errors.WithMessage(err, "failed to set up the connection. Please check the host information") + } + return client.Client, nil + }) + if err != nil { + _ = wshandleError(wsConn, err) } - defer client.Close() +} - sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, command) - if wshandleError(wsConn, err) { +// @Tags Terminal +// @Summary List the caller's live terminal sessions +// @Success 200 {array} terminal.Info +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /hosts/terminal/sessions/search [post] +func (b *BaseApi) SearchTerminalSessions(c *gin.Context) { + helper.SuccessWithData(c, terminal.List(loadAuditUser(c))) +} + +// @Tags Terminal +// @Summary Close a terminal session +// @Accept json +// @Param request body dto.TerminalSessionClose true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /hosts/terminal/sessions/close [post] +func (b *BaseApi) CloseTerminalSession(c *gin.Context) { + var req dto.TerminalSessionClose + if err := helper.CheckBindAndValidate(&req, c); err != nil { return } - defer sws.Close() - - quitChan := make(chan bool, 3) - sws.Start(quitChan) - go sws.Wait(quitChan) + if err := terminal.CloseSession(req.ID, loadAuditUser(c)); err != nil { + helper.BadRequest(c, err) + return + } + helper.Success(c) +} - <-quitChan +// @Tags Terminal +// @Summary Close every terminal session (panel user logged out) +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /hosts/terminal/sessions/closeAll [post] +func (b *BaseApi) CloseAllTerminalSessions(c *gin.Context) { + terminal.CloseAll() + helper.Success(c) +} - closeTerminalConn(wsConn) +// sanitizeTerminalTitle keeps the title a short single line. +func sanitizeTerminalTitle(title string) string { + title = strings.Join(strings.Fields(title), " ") + if r := []rune(title); len(r) > 64 { + title = string(r[:64]) + } + return title } func closeTerminalConn(wsConn *websocket.Conn) { diff --git a/agent/app/dto/terminal.go b/agent/app/dto/terminal.go new file mode 100644 index 000000000000..017c55eb8f5d --- /dev/null +++ b/agent/app/dto/terminal.go @@ -0,0 +1,5 @@ +package dto + +type TerminalSessionClose struct { + ID string `json:"id" validate:"required"` +} diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 24169c5234e1..01d6437d3fdf 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'xfs not found; install xfsprogs first' # terminal TerminalAIBlockedRiskyCommand: 'blocked risky command: {{ .command }}' TerminalAIThinking: 'AI is thinking...' +TerminalOutputTruncated: '[output truncated, showing recent output only]' TerminalAIReadyToExecute: 'Thinking complete, press Enter to execute (duration: {{ .duration }}, tokens: {{ .tokens }})' TerminalAIRequestFailed: 'AI request failed: {{ .err }}' diff --git a/agent/i18n/lang/es-ES.yaml b/agent/i18n/lang/es-ES.yaml index 7dbb9f35aed9..d468fead6347 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'No se detectó el sistema de archivos xfs instale xfsprogs primero # terminal TerminalAIBlockedRiskyCommand: 'Comando de riesgo bloqueado: {{ .command }}' TerminalAIThinking: 'La IA está pensando...' +TerminalOutputTruncated: '[salida truncada, se muestra solo la salida reciente]' TerminalAIReadyToExecute: 'Pensamiento completado, pulsa Enter para ejecutar (duración: {{ .duration }}, token: {{ .tokens }})' TerminalAIRequestFailed: 'La solicitud de AI ha fallado: {{ .err }}' diff --git a/agent/i18n/lang/fa.yaml b/agent/i18n/lang/fa.yaml index a8b43615b145..51953468b330 100644 --- a/agent/i18n/lang/fa.yaml +++ b/agent/i18n/lang/fa.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'xfs یافت نشد؛ ابتدا xfsprogs را نصب کنید' # ترمینال TerminalAIBlockedRiskyCommand: 'دستور پرخطر مسدود شد: {{ .command }}' TerminalAIThinking: 'هوش مصنوعی در حال فکر کردن است...' +TerminalOutputTruncated: '[خروجی کوتاه شد، فقط خروجی اخیر نمایش داده می‌شود]' TerminalAIReadyToExecute: 'تفکر کامل شد، برای اجرا Enter را فشار دهید (مدت زمان: {{ .duration }}، توکن‌ها: {{ .tokens }})' TerminalAIRequestFailed: 'درخواست هوش مصنوعی ناموفق بود: {{ .err }}' diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index 424984718b52..2b80b87f174d 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'xfs ファイルシステムが検出されませんでした、 # ターミナル TerminalAIBlockedRiskyCommand: '危険なコマンドをブロックしました: {{ .command }}' TerminalAIThinking: 'AI が考えています...' +TerminalOutputTruncated: '[出力は切り詰められました。最近の出力のみ表示しています]' TerminalAIReadyToExecute: '思考が完了しました。Enter で実行してください(所要時間 {{ .duration }}、token: {{ .tokens }})' TerminalAIRequestFailed: 'AI リクエストに失敗しました: {{ .err }}' diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index def88719d194..5d771e7fbcfc 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'xfs 파일 시스템이 감지되지 않았습니다, 먼저 xfspr # 터미널 TerminalAIBlockedRiskyCommand: '위험한 명령이 차단되었습니다: {{ .command }}' TerminalAIThinking: 'AI가 생각 중입니다...' +TerminalOutputTruncated: '[출력이 잘렸습니다. 최근 출력만 표시합니다]' TerminalAIReadyToExecute: '생각이 완료되었습니다. Enter를 눌러 실행하세요 (소요 시간 {{ .duration }}, token: {{ .tokens }})' TerminalAIRequestFailed: 'AI 요청 실패: {{ .err }}' diff --git a/agent/i18n/lang/lo.yaml b/agent/i18n/lang/lo.yaml index 41439c92c87b..0b5786482066 100644 --- a/agent/i18n/lang/lo.yaml +++ b/agent/i18n/lang/lo.yaml @@ -668,6 +668,7 @@ XfsNotFound: 'ບໍ່ພົບ xfs; ກະລຸນາຕິດຕັ້ງ xf # terminal TerminalAIBlockedRiskyCommand: 'ບລັອກຄຳສັ່ງທີ່ມີຄວາມສ່ຽງ: {{ .command }}' TerminalAIThinking: 'AI ກຳລັງຄິດ...' +TerminalOutputTruncated: '[ຜົນລັບຖືກຕັດ, ສະແດງສະເພາະຜົນລັບຫຼ້າສຸດ]' TerminalAIReadyToExecute: 'ຄິດສຳເລັດແລ້ວ, ກົດ Enter ເພື່ອປະຕິບັດ (ໃຊ້ເວລາ: {{ .duration }}, ໂທເຄັນ: {{ .tokens }})' TerminalAIRequestFailed: 'ຄຳຮ້ອງຂໍ AI ລົ້ມເຫຼວ: {{ .err }}' FileAISearchEmptyDir: 'ບໍ່ພົບໄຟລ໌ ຫຼື ໂຟນເດີພາຍໃຕ້ເສັ້ນທາງນີ້ (ຫຼື ລາຍການທັງໝົດຖືກກັ່ນກອງອອກ).' diff --git a/agent/i18n/lang/ms.yaml b/agent/i18n/lang/ms.yaml index 261fed1cdbbd..f526351d1803 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'Sistem fail xfs tidak dikesan, sila pasang xfsprogs terlebih dahul # terminal TerminalAIBlockedRiskyCommand: 'Perintah berisiko telah disekat: {{ .command }}' TerminalAIThinking: 'AI sedang berfikir...' +TerminalOutputTruncated: '[output dipotong, hanya output terkini dipaparkan]' TerminalAIReadyToExecute: 'Pemikiran selesai, tekan Enter untuk jalankan (tempoh: {{ .duration }}, token: {{ .tokens }})' TerminalAIRequestFailed: 'Permintaan AI gagal: {{ .err }}' diff --git a/agent/i18n/lang/pt-BR.yaml b/agent/i18n/lang/pt-BR.yaml index e653b0d670d5..eeb04167a073 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'Sistema de arquivos xfs não detectado instale xfsprogs primeiro' # terminal TerminalAIBlockedRiskyCommand: 'Comando de risco bloqueado: {{ .command }}' TerminalAIThinking: 'A IA está pensando...' +TerminalOutputTruncated: '[saída truncada, mostrando apenas a saída recente]' TerminalAIReadyToExecute: 'Pensamento concluído, pressione Enter para executar (duração: {{ .duration }}, token: {{ .tokens }})' TerminalAIRequestFailed: 'Falha na solicitação de AI: {{ .err }}' diff --git a/agent/i18n/lang/ru.yaml b/agent/i18n/lang/ru.yaml index 54bcc7a7c744..7f4ab1081f4a 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'Файловая система xfs не обнаружена, с # терминал TerminalAIBlockedRiskyCommand: 'Опасная команда заблокирована: {{ .command }}' TerminalAIThinking: 'AI думает...' +TerminalOutputTruncated: '[вывод усечён, показан только недавний вывод]' TerminalAIReadyToExecute: 'Обдумывание завершено, нажмите Enter для выполнения (время: {{ .duration }}, token: {{ .tokens }})' TerminalAIRequestFailed: 'Ошибка запроса AI: {{ .err }}' diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index fcbac0798190..ed26b3729625 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -678,6 +678,7 @@ XfsNotFound: 'XFS dosya sistemi algılanmadı, lütfen önce xfsprogs''i yükley # terminal TerminalAIBlockedRiskyCommand: 'Riskli komut engellendi: {{ .command }}' TerminalAIThinking: 'AI dusunuyor...' +TerminalOutputTruncated: '[çıktı kısaltıldı, yalnızca son çıktı gösteriliyor]' TerminalAIReadyToExecute: 'Dusunme tamamlandi, calistirmak icin Enter tusuna basin (sure: {{ .duration }}, token: {{ .tokens }})' TerminalAIRequestFailed: 'AI istegi basarisiz oldu: {{ .err }}' diff --git a/agent/i18n/lang/zh-Hant.yaml b/agent/i18n/lang/zh-Hant.yaml index c22161fb3c1c..a47a00ad0b34 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -678,6 +678,7 @@ XfsNotFound: '未偵測到 xfs 檔案系統,請先安裝 xfsprogs' # 終端 TerminalAIBlockedRiskyCommand: '已攔截風險命令:{{ .command }}' TerminalAIThinking: 'AI 正在思考...' +TerminalOutputTruncated: '[輸出已截斷,僅顯示最近輸出]' TerminalAIReadyToExecute: '思考完成,請按 Enter 執行(耗時 {{ .duration }},token:{{ .tokens }})' TerminalAIRequestFailed: 'AI 請求失敗:{{ .err }}' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 22f0aa967716..1c4f21844a3c 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -678,6 +678,7 @@ XfsNotFound: "未检测到 xfs 文件系统,请先安装 xfsprogs" # 终端 TerminalAIBlockedRiskyCommand: "已拦截风险命令:{{ .command }}" TerminalAIThinking: "AI 正在思考..." +TerminalOutputTruncated: "[输出已截断,仅显示最近输出]" TerminalAIReadyToExecute: "思考完成,请回车执行(耗时 {{ .duration }},token:{{ .tokens }})" TerminalAIRequestFailed: "AI 请求失败:{{ .err }}" diff --git a/agent/router/ro_host.go b/agent/router/ro_host.go index 8e800628e714..5ec475aa258c 100644 --- a/agent/router/ro_host.go +++ b/agent/router/ro_host.go @@ -84,6 +84,9 @@ func (s *HostRouter) InitRouter(Router *gin.RouterGroup) { hostRouter.GET("/terminal/local", baseApi.WsLocalTerminal) hostRouter.GET("/terminal/ssh", baseApi.WsHostSSH) hostRouter.GET("/terminal/container", baseApi.WsContainerTerminal) + hostRouter.POST("/terminal/sessions/search", baseApi.SearchTerminalSessions) + hostRouter.POST("/terminal/sessions/close", baseApi.CloseTerminalSession) + hostRouter.POST("/terminal/sessions/closeAll", baseApi.CloseAllTerminalSessions) hostRouter.GET("/disks", baseApi.GetCompleteDiskInfo) hostRouter.POST("/disks/partition", baseApi.PartitionDisk) diff --git a/agent/utils/terminal/attachment.go b/agent/utils/terminal/attachment.go new file mode 100644 index 000000000000..3b2f764ac05b --- /dev/null +++ b/agent/utils/terminal/attachment.go @@ -0,0 +1,179 @@ +package terminal + +import ( + "encoding/base64" + "encoding/json" + "errors" + "strings" + "sync" + "time" + + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/i18n" + "github.com/gorilla/websocket" +) + +// Half-open detection uses protocol level ping/pong, which browsers answer +// without JavaScript, so background tab timer throttling cannot trip it. +const ( + pingInterval = 30 * time.Second + pongWait = 75 * time.Second + writeWait = 5 * time.Second +) + +var errAttachmentClosed = errors.New("terminal attachment is closed") + +// attachment is one websocket connection bound to a Session. +type attachment struct { + sess *Session + ws *websocket.Conn + + writeMu sync.Mutex + cursor uint64 // ring offset of the next byte to send; guarded by writeMu + + done chan struct{} + closeOnce sync.Once +} + +// Run reads client messages until the websocket fails or this attachment is closed. +// On return the session is detached: cleanly if the client sent close code 1000. +func (a *attachment) Run() { + clean := false + defer func() { + if r := recover(); r != nil { + global.LOG.Errorf("[A panic occurred during receive ws message, error message: %v", r) + } + a.close(websocket.CloseNormalClosure, "") + a.sess.detach(a, clean) + }() + + _ = a.ws.SetReadDeadline(time.Now().Add(pongWait)) + a.ws.SetPongHandler(func(string) error { + return a.ws.SetReadDeadline(time.Now().Add(pongWait)) + }) + go a.pingLoop() + + // close() shuts the websocket, which is what ends this loop. + for { + _, wsData, err := a.ws.ReadMessage() + if err != nil { + clean = websocket.IsCloseError(err, websocket.CloseNormalClosure) + return + } + _ = a.ws.SetReadDeadline(time.Now().Add(pongWait)) + msgObj := WsMsg{} + _ = json.Unmarshal(wsData, &msgObj) + switch msgObj.Type { + case WsMsgResize: + if msgObj.Cols > 0 && msgObj.Rows > 0 { + a.sess.resize(msgObj.Cols, msgObj.Rows) + } + case WsMsgCmd: + decodeBytes, err := base64.StdEncoding.DecodeString(msgObj.Data) + if err != nil { + global.LOG.Errorf("websock cmd string base64 decoding failed, err: %v", err) + } + if isEnterInput(decodeBytes) { + interceptor := a.sess.ensureAIInterceptor() + if interceptor != nil { + interceptor.SetCurrentLine(msgObj.Line) + } + if generated, handled := interceptor.HandleEnter(a.notifyAIThinking, a.notifyAIDone, a.notifyAIError); handled { + if payload, err := buildAIPastePayload(generated); err != nil { + global.LOG.Errorf("ai generated command rejected before ssh.stdin pipe write, err: %v", err) + } else { + a.sess.writeInput(payload) + } + continue + } + } + a.sess.writeInput(decodeBytes) + case WsMsgHeartbeat: + if err := a.write(wsData); err != nil { + global.LOG.Errorf("ssh sending heartbeat to webSocket failed, err: %v", err) + } + } + } +} + +// pingLoop keeps the read deadline honest; a ping that cannot be sent ends the attachment. +func (a *attachment) pingLoop() { + tick := time.NewTicker(pingInterval) + defer tick.Stop() + for { + select { + case <-a.done: + return + case <-tick.C: + if err := a.ws.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeWait)); err != nil { + a.close(websocket.CloseInternalServerErr, "ping failed") + return + } + } + } +} + +// write sends one text message, serialized against every other writer. +func (a *attachment) write(data []byte) error { + a.writeMu.Lock() + defer a.writeMu.Unlock() + return a.writeLocked(data) +} + +// writeLocked sends one text message; the caller owns writeMu. +func (a *attachment) writeLocked(data []byte) error { + select { + case <-a.done: + return errAttachmentClosed + default: + } + _ = a.ws.SetWriteDeadline(time.Now().Add(writeWait)) + return a.ws.WriteMessage(websocket.TextMessage, data) +} + +// close sends a close frame and tears the websocket down. Idempotent. +func (a *attachment) close(code int, reason string) { + a.closeOnce.Do(func() { + defer func() { + if r := recover(); r != nil { + global.LOG.Errorf("a panic occurred during close ws attachment, error message: %v", r) + } + }() + close(a.done) + sendClose(a.ws, code, reason) + _ = a.ws.Close() + }) +} + +func (a *attachment) notifyAIThinking() { + if err := a.writeAINotice("info", i18n.GetMsgByKeyAndLang(a.sess.lang, "TerminalAIThinking")); err != nil { + global.LOG.Errorf("write terminal ai thinking message failed, err: %v", err) + } +} + +func (a *attachment) notifyAIDone(message string) { + if err := a.writeAINotice("success", message); err != nil { + global.LOG.Errorf("write terminal ai done message failed, err: %v", err) + } +} + +func (a *attachment) notifyAIError(message string) { + if err := a.writeAINotice("error", message); err != nil { + global.LOG.Errorf("write terminal ai error message failed, err: %v", err) + } +} + +func (a *attachment) writeAINotice(level, message string) error { + if strings.TrimSpace(message) == "" { + return nil + } + wsData, err := json.Marshal(WsMsg{ + Type: WsMsgAINotice, + Level: strings.TrimSpace(level), + Message: strings.TrimSpace(message), + }) + if err != nil { + return err + } + return a.write(wsData) +} diff --git a/agent/utils/terminal/registry.go b/agent/utils/terminal/registry.go new file mode 100644 index 000000000000..8ce8755dc70b --- /dev/null +++ b/agent/utils/terminal/registry.go @@ -0,0 +1,58 @@ +package terminal + +import ( + "errors" + "sort" + "sync" +) + +// sessions is the process wide registry of live sessions, keyed by id. +// Open stores, Close deletes. +var sessions sync.Map + +var errSessionNotFound = errors.New("terminal session not found") + +// Lookup resolves id for owner. Foreign and unknown sessions look the same. +func Lookup(id, owner string) (*Session, bool) { + v, ok := sessions.Load(id) + if !ok { + return nil, false + } + s := v.(*Session) + if s.Owner != "" && owner != "" && s.Owner != owner { + return nil, false + } + return s, true +} + +// List returns owner's sessions, oldest first. +func List(owner string) []Info { + var out []Info + sessions.Range(func(_, v any) bool { + s := v.(*Session) + if s.Owner == "" || owner == "" || s.Owner == owner { + out = append(out, s.Info()) + } + return true + }) + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) + return out +} + +// CloseAll ends every live session; core calls it when the panel user logs out. +func CloseAll() { + sessions.Range(func(_, v any) bool { + v.(*Session).Close() + return true + }) +} + +// CloseSession closes id on behalf of owner. +func CloseSession(id, owner string) error { + s, ok := Lookup(id, owner) + if !ok { + return errSessionNotFound + } + s.Close() + return nil +} diff --git a/agent/utils/terminal/ringbuf.go b/agent/utils/terminal/ringbuf.go new file mode 100644 index 000000000000..6cfa3c7901c9 --- /dev/null +++ b/agent/utils/terminal/ringbuf.go @@ -0,0 +1,91 @@ +package terminal + +import ( + "bytes" + "sync" +) + +// ringSize is the output retained per session. Reattach replays at most this much. +// ponytail: fixed; make it a setting if someone asks for a bigger tail. +const ringSize = 128 * 1024 + +// ringBuffer is a fixed capacity byte ring addressed by absolute write offset. +// Write never blocks and overwrites the oldest bytes; readers that fall behind +// skip ahead and are told they lost data. +type ringBuffer struct { + mu sync.Mutex + buf []byte + written uint64 // total bytes ever written; offset of the next byte +} + +func newRingBuffer() *ringBuffer { + return &ringBuffer{buf: make([]byte, ringSize)} +} + +// Write appends p, dropping the oldest bytes when full. Always reports len(p). +func (r *ringBuffer) Write(p []byte) (int, error) { + n := len(p) + if n == 0 { + return 0, nil + } + r.mu.Lock() + defer r.mu.Unlock() + capacity := len(r.buf) + if n > capacity { + // only the tail can survive; account for the skipped bytes so offsets stay absolute + r.written += uint64(n - capacity) + p = p[n-capacity:] + } + pos := int(r.written % uint64(capacity)) + k := copy(r.buf[pos:], p) + copy(r.buf, p[k:]) + r.written += uint64(len(p)) + return n, nil +} + +// Oldest is the offset of the oldest byte still retained. +func (r *ringBuffer) Oldest() uint64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.oldestLocked() +} + +func (r *ringBuffer) oldestLocked() uint64 { + if capacity := uint64(len(r.buf)); r.written > capacity { + return r.written - capacity + } + return 0 +} + +// ReadFrom returns every byte from offset onward and the offset to continue from. +// If offset was already overwritten, reading starts at the oldest retained byte +// and lost is true. Whenever the start is not the true beginning of output the +// result is aligned to the next '\n' so replay never begins mid escape sequence. +func (r *ringBuffer) ReadFrom(offset uint64) (data []byte, next uint64, lost bool) { + r.mu.Lock() + defer r.mu.Unlock() + oldest := r.oldestLocked() + if offset > r.written { + offset = r.written + } + if offset < oldest { + offset = oldest + lost = true + } + n := int(r.written - offset) + if n == 0 { + return nil, r.written, lost + } + out := make([]byte, n) + start := int(offset % uint64(len(r.buf))) + k := copy(out, r.buf[start:min(start+n, len(r.buf))]) + if k < n { + copy(out[k:], r.buf[:n-k]) + } + if offset == oldest && oldest > 0 { + if idx := bytes.IndexByte(out, '\n'); idx >= 0 && idx+1 < len(out) { + out = out[idx+1:] + } + } + return out, r.written, lost +} diff --git a/agent/utils/terminal/session.go b/agent/utils/terminal/session.go new file mode 100644 index 000000000000..c82a9995fd2f --- /dev/null +++ b/agent/utils/terminal/session.go @@ -0,0 +1,396 @@ +package terminal + +import ( + "encoding/base64" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/i18n" + terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai" + "github.com/google/uuid" + "github.com/gorilla/websocket" + gossh "golang.org/x/crypto/ssh" +) + +// Lifetime rules. A session outlives its websocket: +// - the client closes its websocket with 1000 -> the shell is closed at once +// - the websocket drops any other way (browser tab closed, network) -> the +// shell waits graceTimeout for a reattach, then is closed +// +// ponytail: all fixed; promote to settings only if someone asks. +const ( + graceTimeout = 30 * time.Minute + keepaliveInterval = 30 * time.Second + keepaliveTimeout = 10 * time.Second + pumpInterval = 60 * time.Millisecond +) + +// Websocket close codes of the session protocol; the frontend switches on them. +const ( + // CloseCodeSessionNotFound: the session is gone or not the caller's; do not retry. + CloseCodeSessionNotFound = 4404 + // CloseCodeAttachedElsewhere: a newer websocket took over the session. + CloseCodeAttachedElsewhere = 4409 +) + +var errSessionClosed = errors.New("terminal session is closed") + +// SessionOptions describes a session that is about to be created. +type SessionOptions struct { + Owner string + Title string + HostID uint // 0 = local shell + Cols int + Rows int + InitCmd string +} + +// Info is the client visible snapshot of a session. +type Info struct { + ID string `json:"id"` + Title string `json:"title"` + HostID uint `json:"hostId"` + Attached bool `json:"attached"` + CreatedAt time.Time `json:"createdAt"` + DetachedAt time.Time `json:"detachedAt"` // zero while attached +} + +// Session owns one shell; a websocket is only a detachable attachment. +type Session struct { + ID string + Owner string + Title string + HostID uint + CreatedAt time.Time + + mu sync.Mutex + attached *attachment + detachedAt time.Time + grace *time.Timer + cols int + rows int + + backend *sshBackend + ring *ringBuffer + + lang string + aiInterceptor *aiInputInterceptor + aiVersion uint64 + + done chan struct{} + closeFn func() +} + +// Serve drives ws until it ends: it reattaches to sessionID when given, and +// otherwise opens a fresh shell on the client that connect returns. A returned +// error has not been reported to the client yet. +func Serve(ws *websocket.Conn, sessionID string, opts SessionOptions, connect func() (*gossh.Client, error)) error { + if sessionID != "" { + sess, ok := Lookup(sessionID, opts.Owner) + if ok { + att, err := sess.Attach(ws, opts.Cols, opts.Rows) + if err == nil { + att.Run() + return nil + } + global.LOG.Errorf("attach terminal session %s failed, err: %v", sessionID, err) + } + sendClose(ws, CloseCodeSessionNotFound, "session not found") + return nil + } + + client, err := connect() + if err != nil { + return err + } + sess, err := Open(client, opts) + if err != nil { + _ = client.Close() + return err + } + // no sess.Close() on return: a dirty disconnect leaves the shell alive for a reattach + att, err := sess.Attach(ws, opts.Cols, opts.Rows) + if err != nil { + sess.Close() + return err + } + att.Run() + return nil +} + +// Open starts a shell on client and registers the session. +func Open(client *gossh.Client, opts SessionOptions) (*Session, error) { + ring := newRingBuffer() + backend, err := newSSHBackend(client, opts.Cols, opts.Rows, opts.InitCmd, ring) + if err != nil { + return nil, err + } + lang := i18n.GetLanguageFromDB() + s := &Session{ + ID: uuid.NewString(), + Owner: opts.Owner, + Title: opts.Title, + HostID: opts.HostID, + CreatedAt: time.Now(), + cols: opts.Cols, + rows: opts.Rows, + backend: backend, + ring: ring, + lang: lang, + aiInterceptor: newAIInputInterceptor("", lang), + aiVersion: terminalai.CurrentTerminalRuntimeVersion(), + done: make(chan struct{}), + } + s.closeFn = sync.OnceFunc(s.doClose) + sessions.Store(s.ID, s) + go s.pump() + go s.keepaliveLoop() + go s.waitBackend() + return s, nil +} + +// Attach binds ws to the session, kicking any previous attachment, and replays +// the retained output tail before any live output. +func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error) { + if ws == nil { + return nil, errors.New("nil websocket connection") + } + att := &attachment{sess: s, ws: ws, done: make(chan struct{})} + + s.mu.Lock() + select { + case <-s.done: + s.mu.Unlock() + return nil, errSessionClosed + default: + } + previous := s.attached + s.attached = att + s.detachedAt = time.Time{} + if s.grace != nil { + s.grace.Stop() + s.grace = nil + } + if cols > 0 { + s.cols = cols + } + if rows > 0 { + s.rows = rows + } + cols, rows = s.cols, s.rows + att.cursor = s.ring.Oldest() + // Hold writeMu across unlock so hello+replay go out before the pump can write. + att.writeMu.Lock() + s.mu.Unlock() + + if previous != nil { + previous.close(CloseCodeAttachedElsewhere, "attached elsewhere") + } + + err := func() error { + defer att.writeMu.Unlock() + hello, err := json.Marshal(WsMsg{Type: WsMsgSession, ID: s.ID}) + if err != nil { + return err + } + if err := att.writeLocked(hello); err != nil { + return err + } + data, next, _ := s.ring.ReadFrom(att.cursor) + att.cursor = next + if len(data) == 0 { + return nil + } + return att.writeLocked(cmdMessage(data)) + }() + if err != nil { + att.close(websocket.CloseInternalServerErr, "attach failed") + s.detach(att, false) + return nil, err + } + + if err := s.backend.Resize(cols, rows); err != nil { + global.LOG.Errorf("ssh pty change windows size failed, err: %v", err) + } + return att, nil +} + +// detach unbinds a. A clean detach closes the shell; a dirty one arms the grace timer. +func (s *Session) detach(a *attachment, clean bool) { + s.mu.Lock() + if s.attached != a { + s.mu.Unlock() + return + } + s.attached = nil + s.detachedAt = time.Now() + if !clean { + s.grace = time.AfterFunc(graceTimeout, s.Close) + } + s.mu.Unlock() + global.LOG.Debugf("terminal session %s detached, clean=%v", s.ID, clean) + if clean { + s.Close() + } +} + +// Close terminates the shell and any attachment. Idempotent. +func (s *Session) Close() { s.closeFn() } + +func (s *Session) doClose() { + close(s.done) + s.mu.Lock() + att := s.attached + s.attached = nil + if s.grace != nil { + s.grace.Stop() + } + s.mu.Unlock() + if att != nil { + att.close(websocket.CloseNormalClosure, "") + } + if err := s.backend.Close(); err != nil { + global.LOG.Debugf("close terminal backend: %v", err) + } + sessions.Delete(s.ID) +} + +// Info snapshots the session for listing. +func (s *Session) Info() Info { + s.mu.Lock() + defer s.mu.Unlock() + return Info{ + ID: s.ID, + Title: s.Title, + HostID: s.HostID, + Attached: s.attached != nil, + CreatedAt: s.CreatedAt, + DetachedAt: s.detachedAt, + } +} + +// resize forwards a window size change to the shell. +func (s *Session) resize(cols, rows int) { + s.mu.Lock() + s.cols, s.rows = cols, rows + s.mu.Unlock() + if err := s.backend.Resize(cols, rows); err != nil { + global.LOG.Errorf("ssh pty change windows size failed, err: %v", err) + } +} + +// writeInput forwards client input to the shell stdin. +func (s *Session) writeInput(data []byte) { + if _, err := s.backend.Write(data); err != nil { + global.LOG.Errorf("ws cmd bytes write to ssh.stdin pipe failed, err: %v", err) + } +} + +// ensureAIInterceptor rebuilds the interceptor when AI runtime settings change. +func (s *Session) ensureAIInterceptor() *aiInputInterceptor { + s.mu.Lock() + defer s.mu.Unlock() + if v := terminalai.CurrentTerminalRuntimeVersion(); s.aiInterceptor == nil || s.aiVersion != v { + s.aiVersion = v + s.aiInterceptor = newAIInputInterceptor("", s.lang) + } + return s.aiInterceptor +} + +// pump forwards new ring output to the current attachment. +func (s *Session) pump() { + defer func() { + if r := recover(); r != nil { + global.LOG.Errorf("a panic occurred during send combo output, error message: %v", r) + } + }() + tick := time.NewTicker(pumpInterval) + defer tick.Stop() + for { + select { + case <-s.done: + return + case <-tick.C: + s.flush() + } + } +} + +// flush sends everything the attachment has not seen yet. A client that fell +// behind the ring skips ahead and is told so; output is never queued unbounded. +func (s *Session) flush() { + s.mu.Lock() + att := s.attached + s.mu.Unlock() + if att == nil { + return + } + att.writeMu.Lock() + defer att.writeMu.Unlock() + data, next, lost := s.ring.ReadFrom(att.cursor) + if lost { + notice := "\r\n\x1b[33m" + i18n.GetMsgByKeyAndLang(s.lang, "TerminalOutputTruncated") + "\x1b[m\r\n" + if err := att.writeLocked(cmdMessage([]byte(notice))); err != nil { + att.close(websocket.CloseInternalServerErr, "write failed") + return + } + } + if len(data) == 0 { + return + } + if err := att.writeLocked(cmdMessage(data)); err != nil { + global.LOG.Errorf("ssh sending combo output to webSocket failed, err: %v", err) + att.close(websocket.CloseInternalServerErr, "write failed") + return + } + att.cursor = next +} + +// keepaliveLoop probes the shell connection; a failed or stuck probe closes the session. +func (s *Session) keepaliveLoop() { + tick := time.NewTicker(keepaliveInterval) + defer tick.Stop() + for { + select { + case <-s.done: + return + case <-tick.C: + result := make(chan error, 1) + go func() { result <- s.backend.Keepalive() }() + select { + case err := <-result: + if err != nil { + global.LOG.Infof("terminal session %s keepalive failed: %v", s.ID, err) + s.Close() + return + } + case <-time.After(keepaliveTimeout): + global.LOG.Infof("terminal session %s keepalive timed out", s.ID) + s.Close() + return + case <-s.done: + return + } + } + } +} + +// waitBackend closes the session once the shell exits, after a last flush. +func (s *Session) waitBackend() { + _ = s.backend.Wait() + s.flush() + s.Close() +} + +func cmdMessage(data []byte) []byte { + msg, _ := json.Marshal(WsMsg{Type: WsMsgCmd, Data: base64.StdEncoding.EncodeToString(data)}) + return msg +} + +// sendClose writes a close frame with code and reason, best effort. +func sendClose(ws *websocket.Conn, code int, reason string) { + _ = ws.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), time.Now().Add(time.Second)) +} diff --git a/agent/utils/terminal/ssh_backend.go b/agent/utils/terminal/ssh_backend.go new file mode 100644 index 000000000000..9e10e10a0bee --- /dev/null +++ b/agent/utils/terminal/ssh_backend.go @@ -0,0 +1,85 @@ +package terminal + +import ( + "io" + "time" + + gossh "golang.org/x/crypto/ssh" +) + +// sshBackend drives one interactive shell and owns its ssh client. +type sshBackend struct { + client *gossh.Client + session *gossh.Session + stdin io.WriteCloser +} + +// newSSHBackend opens a shell with a pty on client and streams its output to out. +func newSSHBackend(client *gossh.Client, cols, rows int, initCmd string, out io.Writer) (*sshBackend, error) { + sshSession, err := client.NewSession() + if err != nil { + return nil, err + } + stdinPipe, err := sshSession.StdinPipe() + if err != nil { + _ = sshSession.Close() + return nil, err + } + sshSession.Stdout = out + sshSession.Stderr = out + + modes := gossh.TerminalModes{ + gossh.ECHO: 1, + gossh.TTY_OP_ISPEED: 14400, + gossh.TTY_OP_OSPEED: 14400, + } + if err := sshSession.RequestPty("xterm", rows, cols, modes); err != nil { + _ = sshSession.Close() + return nil, err + } + if err := sshSession.Shell(); err != nil { + _ = sshSession.Close() + return nil, err + } + if len(initCmd) != 0 { + time.Sleep(100 * time.Millisecond) + _, _ = stdinPipe.Write([]byte(initCmd + "\n")) + } + return &sshBackend{client: client, session: sshSession, stdin: stdinPipe}, nil +} + +// Write forwards p to the shell stdin. +func (b *sshBackend) Write(p []byte) (int, error) { + return b.stdin.Write(p) +} + +// Resize changes the pty window size. +func (b *sshBackend) Resize(cols, rows int) error { + return b.session.WindowChange(rows, cols) +} + +// Wait blocks until the remote shell exits. +func (b *sshBackend) Wait() error { + return b.session.Wait() +} + +// Keepalive probes the ssh connection; callers should bound it. +func (b *sshBackend) Keepalive() error { + if b.client == nil { + return nil + } + // a failure reply with no error still proves the connection is alive + _, _, err := b.client.SendRequest("keepalive@openssh.com", true, nil) + return err +} + +// Close terminates the ssh session and the ssh client owned by this backend. +func (b *sshBackend) Close() error { + err := b.session.Close() + if b.client != nil { + if clientErr := b.client.Close(); err == nil { + err = clientErr + } + } + return err +} diff --git a/agent/utils/terminal/ws_msg.go b/agent/utils/terminal/ws_msg.go new file mode 100644 index 000000000000..1a8042498256 --- /dev/null +++ b/agent/utils/terminal/ws_msg.go @@ -0,0 +1,26 @@ +package terminal + +const ( + WsMsgCmd = "cmd" + WsMsgResize = "resize" + WsMsgHeartbeat = "heartbeat" + WsMsgAINotice = "ai_notice" + // WsMsgSession is the first message after attach; carries the session id. + WsMsgSession = "session" +) + +type WsMsg struct { + Type string `json:"type"` + Data string `json:"data,omitempty"` // WsMsgCmd + Line string `json:"line,omitempty"` // WsMsgCmd + Level string `json:"level,omitempty"` // WsMsgAINotice + Message string `json:"message,omitempty"` // WsMsgAINotice + Cols int `json:"cols,omitzero"` // WsMsgResize + Rows int `json:"rows,omitzero"` // WsMsgResize + Timestamp int `json:"timestamp,omitzero"` // WsMsgHeartbeat + ID string `json:"id,omitempty"` // WsMsgSession +} + +func setQuit(ch chan bool) { + ch <- true +} diff --git a/agent/utils/terminal/ws_session.go b/agent/utils/terminal/ws_session.go deleted file mode 100644 index e3c388d6ce84..000000000000 --- a/agent/utils/terminal/ws_session.go +++ /dev/null @@ -1,307 +0,0 @@ -package terminal - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "io" - "strings" - "sync" - "time" - - "github.com/1Panel-dev/1Panel/agent/global" - "github.com/1Panel-dev/1Panel/agent/i18n" - terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai" - "github.com/gorilla/websocket" - "golang.org/x/crypto/ssh" -) - -type safeBuffer struct { - buffer bytes.Buffer - mu sync.Mutex -} - -func (w *safeBuffer) Write(p []byte) (int, error) { - w.mu.Lock() - defer w.mu.Unlock() - return w.buffer.Write(p) -} -func (w *safeBuffer) Bytes() []byte { - w.mu.Lock() - defer w.mu.Unlock() - return w.buffer.Bytes() -} -func (w *safeBuffer) Reset() { - w.mu.Lock() - defer w.mu.Unlock() - w.buffer.Reset() -} - -const ( - WsMsgCmd = "cmd" - WsMsgResize = "resize" - WsMsgHeartbeat = "heartbeat" - WsMsgAINotice = "ai_notice" -) - -type WsMsg struct { - Type string `json:"type"` - Data string `json:"data,omitempty"` // WsMsgCmd - Line string `json:"line,omitempty"` // WsMsgCmd - Level string `json:"level,omitempty"` // WsMsgAINotice - Message string `json:"message,omitempty"` // WsMsgAINotice - Cols int `json:"cols,omitempty"` // WsMsgResize - Rows int `json:"rows,omitempty"` // WsMsgResize - Timestamp int `json:"timestamp,omitempty"` // WsMsgHeartbeat -} - -type LogicSshWsSession struct { - stdinPipe io.WriteCloser - comboOutput *safeBuffer - logBuff *safeBuffer - session *ssh.Session - wsConn *websocket.Conn - writeMutex sync.Mutex - lang string - isAdmin bool - IsFlagged bool - aiInterceptor *aiInputInterceptor - aiVersion uint64 -} - -func NewLogicSshWsSession(cols, rows int, sshClient *ssh.Client, wsConn *websocket.Conn, initCmd string) (*LogicSshWsSession, error) { - sshSession, err := sshClient.NewSession() - if err != nil { - return nil, err - } - - stdinP, err := sshSession.StdinPipe() - if err != nil { - return nil, err - } - - comboWriter := new(safeBuffer) - logBuf := new(safeBuffer) - sshSession.Stdout = comboWriter - sshSession.Stderr = comboWriter - - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - } - if err := sshSession.RequestPty("xterm", rows, cols, modes); err != nil { - return nil, err - } - if err := sshSession.Shell(); err != nil { - return nil, err - } - if len(initCmd) != 0 { - time.Sleep(100 * time.Millisecond) - _, _ = stdinP.Write([]byte(initCmd + "\n")) - } - lang := i18n.GetLanguageFromDB() - return &LogicSshWsSession{ - stdinPipe: stdinP, - comboOutput: comboWriter, - logBuff: logBuf, - session: sshSession, - wsConn: wsConn, - lang: lang, - isAdmin: true, - IsFlagged: false, - aiInterceptor: newAIInputInterceptor("", lang), - aiVersion: terminalai.CurrentTerminalRuntimeVersion(), - }, nil -} - -func (sws *LogicSshWsSession) Close() { - if sws.session != nil { - sws.session.Close() - } - if sws.logBuff != nil { - sws.logBuff = nil - } - if sws.comboOutput != nil { - sws.comboOutput = nil - } -} - -func (sws *LogicSshWsSession) Start(quitChan chan bool) { - go sws.receiveWsMsg(quitChan) - go sws.sendComboOutput(quitChan) -} - -func (sws *LogicSshWsSession) receiveWsMsg(exitCh chan bool) { - defer func() { - if r := recover(); r != nil { - global.LOG.Errorf("[A panic occurred during receive ws message, error message: %v", r) - } - }() - wsConn := sws.wsConn - defer setQuit(exitCh) - for { - select { - case <-exitCh: - return - default: - _, wsData, err := wsConn.ReadMessage() - if err != nil { - return - } - msgObj := WsMsg{} - _ = json.Unmarshal(wsData, &msgObj) - switch msgObj.Type { - case WsMsgResize: - if msgObj.Cols > 0 && msgObj.Rows > 0 { - if err := sws.session.WindowChange(msgObj.Rows, msgObj.Cols); err != nil { - global.LOG.Errorf("ssh pty change windows size failed, err: %v", err) - } - } - case WsMsgCmd: - decodeBytes, err := base64.StdEncoding.DecodeString(msgObj.Data) - if err != nil { - global.LOG.Errorf("websock cmd string base64 decoding failed, err: %v", err) - } - if isEnterInput(decodeBytes) { - sws.ensureAIInterceptor() - if sws.aiInterceptor != nil { - sws.aiInterceptor.SetCurrentLine(msgObj.Line) - } - if generated, handled := sws.aiInterceptor.HandleEnter(sws.notifyAIThinking, sws.notifyAIDone, sws.notifyAIError); handled { - if payload, err := buildAIPastePayload(generated); err != nil { - global.LOG.Errorf("ai generated command rejected before ssh.stdin pipe write, err: %v", err) - } else { - sws.sendWebsocketInputCommandToSshSessionStdinPipe(payload) - } - continue - } - } - sws.sendWebsocketInputCommandToSshSessionStdinPipe(decodeBytes) - case WsMsgHeartbeat: - err = sws.writeWSMessage(websocket.TextMessage, wsData) - if err != nil { - global.LOG.Errorf("ssh sending heartbeat to webSocket failed, err: %v", err) - } - } - } - } -} - -func (sws *LogicSshWsSession) ensureAIInterceptor() { - if sws == nil || sws.aiInterceptor != nil { - return - } - currentVersion := terminalai.CurrentTerminalRuntimeVersion() - if sws.aiVersion == currentVersion { - return - } - sws.aiVersion = currentVersion - sws.aiInterceptor = newAIInputInterceptor("", sws.lang) -} - -func (sws *LogicSshWsSession) notifyAIThinking() { - if sws == nil { - return - } - if err := sws.writeAINotice("info", i18n.GetMsgByKeyAndLang(sws.lang, "TerminalAIThinking")); err != nil { - global.LOG.Errorf("write terminal ai thinking message failed, err: %v", err) - } -} - -func (sws *LogicSshWsSession) notifyAIDone(message string) { - if sws == nil || strings.TrimSpace(message) == "" { - return - } - if err := sws.writeAINotice("success", message); err != nil { - global.LOG.Errorf("write terminal ai done message failed, err: %v", err) - } -} - -func (sws *LogicSshWsSession) notifyAIError(message string) { - if sws == nil || strings.TrimSpace(message) == "" { - return - } - if err := sws.writeAINotice("error", message); err != nil { - global.LOG.Errorf("write terminal ai error message failed, err: %v", err) - } -} - -func (sws *LogicSshWsSession) sendWebsocketInputCommandToSshSessionStdinPipe(cmdBytes []byte) { - if _, err := sws.stdinPipe.Write(cmdBytes); err != nil { - global.LOG.Errorf("ws cmd bytes write to ssh.stdin pipe failed, err: %v", err) - } -} - -func (sws *LogicSshWsSession) writeAINotice(level, message string) error { - if sws == nil || strings.TrimSpace(message) == "" { - return nil - } - wsData, err := json.Marshal(WsMsg{ - Type: WsMsgAINotice, - Level: strings.TrimSpace(level), - Message: strings.TrimSpace(message), - }) - if err != nil { - return err - } - return sws.writeWSMessage(websocket.TextMessage, wsData) -} - -func (sws *LogicSshWsSession) writeWSMessage(messageType int, data []byte) error { - sws.writeMutex.Lock() - defer sws.writeMutex.Unlock() - return sws.wsConn.WriteMessage(messageType, data) -} - -func (sws *LogicSshWsSession) sendComboOutput(exitCh chan bool) { - defer setQuit(exitCh) - - tick := time.NewTicker(time.Millisecond * time.Duration(60)) - defer tick.Stop() - for { - select { - case <-tick.C: - if sws.comboOutput == nil { - return - } - bs := sws.comboOutput.Bytes() - if len(bs) > 0 { - wsData, err := json.Marshal(WsMsg{ - Type: WsMsgCmd, - Data: base64.StdEncoding.EncodeToString(bs), - }) - if err != nil { - global.LOG.Errorf("encoding combo output to json failed, err: %v", err) - continue - } - err = sws.writeWSMessage(websocket.TextMessage, wsData) - if err != nil { - global.LOG.Errorf("ssh sending combo output to webSocket failed, err: %v", err) - } - _, err = sws.logBuff.Write(bs) - if err != nil { - global.LOG.Errorf("combo output to log buffer failed, err: %v", err) - } - sws.comboOutput.buffer.Reset() - } - if string(bs) == string([]byte{13, 10, 108, 111, 103, 111, 117, 116, 13, 10}) { - sws.Close() - return - } - - case <-exitCh: - return - } - } -} - -func (sws *LogicSshWsSession) Wait(quitChan chan bool) { - if err := sws.session.Wait(); err != nil { - setQuit(quitChan) - } -} - -func setQuit(ch chan bool) { - ch <- true -} diff --git a/core/app/service/auth.go b/core/app/service/auth.go index a17b913d790d..040397182e8f 100644 --- a/core/app/service/auth.go +++ b/core/app/service/auth.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net" + "net/http" "strings" "time" @@ -20,6 +21,7 @@ import ( "github.com/1Panel-dev/1Panel/core/init/session/psession" "github.com/1Panel-dev/1Panel/core/utils/encrypt" "github.com/1Panel-dev/1Panel/core/utils/passkey" + "github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local" "github.com/gin-gonic/gin" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" @@ -61,9 +63,19 @@ func (u *AuthService) LogOut(c *gin.Context) error { return err } } + CloseTerminalSessions() return nil } +// CloseTerminalSessions tells the local agent to end every kept-alive web terminal. +// A logged-out panel has nobody watching, so nothing it left running should survive. +// ponytail: local agent only; shells on other nodes are the multi-node proxy's job. +func CloseTerminalSessions() { + if _, err := proxy_local.NewLocalClient("/api/v2/hosts/terminal/sessions/closeAll", http.MethodPost, nil, nil); err != nil { + global.LOG.Warnf("close terminal sessions on logout failed, err: %v", err) + } +} + func (u *AuthService) VerifyCode(code string) (bool, error) { setting, err := settingRepo.Get(repo.WithByKey("SecurityEntrance")) if err != nil { diff --git a/core/app/service/setting.go b/core/app/service/setting.go index db9d3493337a..1ee2e9870ce3 100644 --- a/core/app/service/setting.go +++ b/core/app/service/setting.go @@ -245,6 +245,7 @@ func (u *SettingService) Update(c *gin.Context, key, value string) error { case "BindDomain": if len(value) != 0 { _ = global.SESSION.Clean() + CloseTerminalSessions() } if err := u.clearPasskeySettings(); err != nil { return err @@ -609,6 +610,7 @@ func (u *SettingService) deleteCurrentSession(c *gin.Context) { return } _ = global.SESSION.DeleteByID(sessionUser.ID) + CloseTerminalSessions() } func (u *SettingService) clearPasskeySettings() error { diff --git a/frontend/src/api/interface/terminal.ts b/frontend/src/api/interface/terminal.ts index 94a6eb39f8f5..7b9135af163b 100644 --- a/frontend/src/api/interface/terminal.ts +++ b/frontend/src/api/interface/terminal.ts @@ -7,3 +7,12 @@ export interface ReqTerminal { password: string; key: string; } + +export interface TerminalSession { + id: string; + title: string; + hostId: number; + attached: boolean; + createdAt: string; + detachedAt: string; +} diff --git a/frontend/src/api/modules/terminal.ts b/frontend/src/api/modules/terminal.ts index 45332343a169..f9b1311d42c9 100644 --- a/frontend/src/api/modules/terminal.ts +++ b/frontend/src/api/modules/terminal.ts @@ -1,6 +1,7 @@ import http from '@/api'; import { ResPage } from '../interface'; import { Host } from '../interface/host'; +import { TerminalSession } from '../interface/terminal'; import { encodeBase64Fields } from '@/utils/base64'; import { deepCopy } from '@/utils/misc'; export const searchHosts = (params: Host.SearchWithPage) => { @@ -53,3 +54,10 @@ export const loadLocalConn = () => { export const testLocalConn = () => { return http.post(`/settings/ssh/check`); }; + +// live web terminal sessions of the caller; ssh ones live on the local node, local shells on the operated node +export const searchTerminalSessions = (localNode: boolean) => { + return localNode + ? http.postLocalNode(`/hosts/terminal/sessions/search`) + : http.post(`/hosts/terminal/sessions/search`); +}; diff --git a/frontend/src/components/terminal/dock/index.vue b/frontend/src/components/terminal/dock/index.vue new file mode 100644 index 000000000000..244996ebc625 --- /dev/null +++ b/frontend/src/components/terminal/dock/index.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/frontend/src/components/terminal/host.vue b/frontend/src/components/terminal/host.vue new file mode 100644 index 000000000000..d2044bc3e606 --- /dev/null +++ b/frontend/src/components/terminal/host.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/src/components/terminal/index.vue b/frontend/src/components/terminal/index.vue index 9b6aa10786b1..7a5ce2c146c5 100644 --- a/frontend/src/components/terminal/index.vue +++ b/frontend/src/components/terminal/index.vue @@ -26,8 +26,17 @@ import { TerminalStore } from '@/store'; import { MsgError } from '@/utils/message'; import { checkStreamAuth } from '@/utils/stream-auth'; import { useGlobalStore } from '@/composables/useGlobalStore'; +import i18n from '@/lang'; const { currentNode } = useGlobalStore(); +// session: agent side session id known (fresh or reattached) +// expired: the agent no longer has the session; a reconnect must open a new one +const emit = defineEmits(['session', 'expired']); + +// Close codes of the agent's session protocol (agent/utils/terminal/session.go). +const CLOSE_SESSION_NOT_FOUND = 4404; +const CLOSE_ATTACHED_ELSEWHERE = 4409; + const terminalElement = ref(null); const fitAddon = new FitAddon(); const termReady = ref(false); @@ -37,6 +46,18 @@ const terminalSocket = ref(); const heartbeatTimer = ref(); let initWebSocketToken = 0; const latency = ref(0); +// Reconnect state. Only terminals that received a session hello reconnect; +// the agent keeps a dirty-disconnected session alive for a short grace period. +const sessionId = ref(''); +let wsEndpoint = ''; +let wsArgs = ''; +let closing = false; +let reconnecting = false; +let reconnectStartedAt = 0; +let reconnectDelay = 1000; +let reconnectTimer: ReturnType | null = null; +// Must match graceTimeout in agent/utils/terminal/session.go: past it the agent has dropped the shell. +const reconnectWindow = 30 * 60 * 1000; const initCmd = ref(''); const hideInitCmdEcho = ref(false); const initCmdEchoBuffer = ref(''); @@ -114,6 +135,7 @@ interface WsProps { error: string; initCmd: string; waitForPrompt?: string; + sessionId?: string; } interface TerminalBufferLine { @@ -128,6 +150,7 @@ const acceptParams = (props: WsProps) => { initCmd.value = props.initCmd || ''; waitForPrompt.value = props.waitForPrompt || ''; waitForPromptBuffer.value = ''; + sessionId.value = props.sessionId || ''; init(props.endpoint, props.args); } }); @@ -186,11 +209,14 @@ const initError = (errorInfo: string) => { function onClose(isKeepShow: boolean = false) { initWebSocketToken++; + closing = true; + stopReconnect(); window.removeEventListener('resize', changeTerminalSize); clearAINotice(); webSocketReady.value = false; try { - terminalSocket.value?.close(); + // 1000 tells the agent this is deliberate: close the shell now, no grace period + terminalSocket.value?.close(1000); } catch {} if (heartbeatTimer.value) { clearInterval(Number(heartbeatTimer.value)); @@ -249,6 +275,9 @@ function changeTerminalSize() { const initWebSocket = async (endpoint_: string, args: string = '') => { const token = ++initWebSocketToken; + closing = false; + wsEndpoint = endpoint_; + wsArgs = args; const href = window.location.href; const protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss'; const host = href.split('//')[1].split('/')[0]; @@ -258,11 +287,15 @@ const initWebSocket = async (endpoint_: string, args: string = '') => { if (args.indexOf('operateNode=') !== -1) { conn = `${protocol}://${host}/${endpoint}?cols=${term.value.cols}&rows=${term.value.rows}&${args}`; } + if (sessionId.value) { + conn += `&session=${encodeURIComponent(sessionId.value)}`; + } const authError = await checkStreamAuth(conn); if (token !== initWebSocketToken || !termReady.value) { return; } if (authError) { + reconnecting = false; showWebSocketAuthError(authError); return; } @@ -295,7 +328,8 @@ const showWebSocketAuthError = (message: string) => { const runRealTerminal = () => { webSocketReady.value = true; term.value?.focus(); - if (initCmd.value !== '') { + // a reattached shell already ran its init command + if (initCmd.value !== '' && !sessionId.value) { hideInitCmdEcho.value = true; initCmdEchoBuffer.value = ''; sendMsg(initCmd.value); @@ -359,6 +393,18 @@ const onWSReceive = (message: MessageEvent) => { latency.value = new Date().getTime() - wsMsg.timestamp; break; } + case 'session': { + const wasReconnect = reconnecting; + reconnecting = false; + reconnectDelay = 1000; + sessionId.value = wsMsg.id || ''; + if (wasReconnect) { + // replay is a tail of recent output, start from a clean screen + term.value?.reset(); + } + emit('session', sessionId.value); + break; + } case 'ai_notice': { const message = wsMsg.message?.trim(); if (!message) { @@ -372,6 +418,7 @@ const onWSReceive = (message: MessageEvent) => { const errorRealTerminal = (ex: any) => { clearAINotice(); + if (reconnecting) return; let message = ex.message; if (!message) message = 'disconnected'; term.value.write(`\x1b[31m${message}\x1b[m\r\n`); @@ -385,8 +432,65 @@ const closeRealTerminal = (ev: CloseEvent) => { heartbeatTimer.value = undefined; } terminalSocket.value = undefined; - term.value?.write('The connection has been disconnected.'); - term.value?.write(ev.reason); + if (closing || !sessionId.value) { + // deliberate close, or a terminal without an agent side session (container, app, ...) + term.value?.write('The connection has been disconnected.'); + term.value?.write(ev.reason); + return; + } + switch (ev.code) { + case 1000: // the shell exited or the agent closed it + case CLOSE_SESSION_NOT_FOUND: + sessionId.value = ''; + reconnecting = false; + writeNotice( + '31', + ev.code === 1000 ? 'The connection has been disconnected.' : i18n.global.t('terminal.sessionExpired'), + ); + emit('expired'); + return; + case CLOSE_ATTACHED_ELSEWHERE: + reconnecting = false; + writeNotice('31', i18n.global.t('terminal.sessionKicked')); + return; + default: + scheduleReconnect(); + } +}; + +const writeNotice = (color: string, message: string) => { + term.value?.write(`\r\n\x1b[${color}m${message}\x1b[m\r\n`); +}; + +// scheduleReconnect retries with backoff for as long as the agent keeps a detached session. +const scheduleReconnect = () => { + const now = Date.now(); + if (!reconnecting) { + reconnecting = true; + reconnectStartedAt = now; + reconnectDelay = 1000; + writeNotice('33', i18n.global.t('terminal.sessionReconnecting')); + } else if (now - reconnectStartedAt > reconnectWindow) { + reconnecting = false; + sessionId.value = ''; + writeNotice('31', i18n.global.t('terminal.sessionExpired')); + emit('expired'); + return; + } + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (closing || !sessionId.value) return; + initWebSocket(wsEndpoint, wsArgs); + }, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, 8000); +}; + +const stopReconnect = () => { + reconnecting = false; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } }; const isWsOpen = () => { @@ -510,6 +614,8 @@ defineExpose({ isWsOpen, sendMsg, getLatency: () => latency.value, + // re-fit after the element was moved back into a visible container + refit: () => changeTerminalSize(), }); onBeforeUnmount(() => { diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 5acd9376fa0c..a08670df00ac 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -2105,6 +2105,13 @@ const message = { key: 'Private key', keyPassword: 'Private key password', emptyTerminal: 'No terminal is currently connected.', + sessionReconnecting: 'Connection lost, reconnecting...', + sessionExpired: 'Session is no longer available, press Enter or click reconnect to open a new one', + sessionKicked: 'This session was opened in another window', + sessionCount: '{0} sessions', + minimize: 'Minimize', + closeAllSessions: 'Close all sessions', + closeAllConfirm: 'All terminal sessions will be disconnected and cannot be recovered. Continue?', lineHeight: 'Line Height', letterSpacing: 'Letter Spacing', fontSize: 'Font Size', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 88a4fabd3861..4bb7ca4d9521 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -2146,6 +2146,13 @@ const message = { key: 'Clave privada', keyPassword: 'Contraseña de la clave privada', emptyTerminal: 'No hay ninguna terminal conectada actualmente.', + sessionReconnecting: 'Conexión perdida, reconectando...', + sessionExpired: 'La sesión ya no está disponible, pulse Enter o haga clic en reconectar para abrir una nueva', + sessionKicked: 'Esta sesión se abrió en otra ventana', + sessionCount: '{0} sesiones', + minimize: 'Minimizar', + closeAllSessions: 'Cerrar todas las sesiones', + closeAllConfirm: 'Se desconectarán todas las sesiones de terminal y no se podrán recuperar. ¿Continuar?', lineHeight: 'Altura de línea', letterSpacing: 'Espaciado de letras', fontSize: 'Tamaño de fuente', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index 8b009d93ed0a..b00a29f363ff 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -2084,6 +2084,13 @@ const message = { key: 'کلید خصوصی', keyPassword: 'رمز عبور کلید خصوصی', emptyTerminal: 'در حال حاضر هیچ ترمینالی متصل نیست.', + sessionReconnecting: 'اتصال قطع شد، در حال اتصال مجدد...', + sessionExpired: 'نشست دیگر در دسترس نیست، برای باز کردن نشست جدید Enter را بزنید یا روی اتصال مجدد کلیک کنید', + sessionKicked: 'این نشست در پنجره دیگری باز شده است', + sessionCount: '{0} نشست', + minimize: 'کوچک‌سازی', + closeAllSessions: 'بستن همه نشست‌ها', + closeAllConfirm: 'همه نشست‌های ترمینال قطع می‌شوند و قابل بازیابی نیستند. ادامه می‌دهید؟', lineHeight: 'ارتفاع خط', letterSpacing: 'فاصله بین حروف', fontSize: 'اندازه قلم', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index d959227f3b23..a6f83ca1eb29 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -2095,6 +2095,14 @@ const message = { key: '秘密鍵', keyPassword: '秘密キーパスワード', emptyTerminal: '現在接続されている端子はありません。', + sessionReconnecting: '接続が切断されました。再接続しています...', + sessionExpired: + 'セッションは無効になりました。Enter キーまたは再接続をクリックして新しいセッションを開いてください', + sessionKicked: 'このセッションは別のウィンドウで開かれました', + sessionCount: '{0} セッション', + minimize: '最小化', + closeAllSessions: 'すべてのセッションを閉じる', + closeAllConfirm: 'すべてのターミナルセッションが切断され、復元できません。続行しますか?', lineHeight: '行の高さ', letterSpacing: '文字間隔', fontSize: 'フォントサイズ', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 309ca3f72d5b..0514883f6e64 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -2064,6 +2064,13 @@ const message = { key: '개인 키', keyPassword: '개인 키 비밀번호', emptyTerminal: '현재 연결된 터미널이 없습니다.', + sessionReconnecting: '연결이 끊어졌습니다. 다시 연결하는 중...', + sessionExpired: '세션을 더 이상 사용할 수 없습니다. Enter 키를 누르거나 다시 연결을 클릭하여 새 세션을 여세요', + sessionKicked: '이 세션은 다른 창에서 열렸습니다', + sessionCount: '세션 {0}개', + minimize: '최소화', + closeAllSessions: '모든 세션 닫기', + closeAllConfirm: '모든 터미널 세션이 끊기며 복구할 수 없습니다. 계속하시겠습니까?', lineHeight: '줄 높이', letterSpacing: '자간', fontSize: '글꼴 크기', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index 069dddb45313..6290916a2112 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -2050,6 +2050,13 @@ const message = { key: 'Private key', keyPassword: 'ລະຫັດຜ່ານ Private key', emptyTerminal: 'ຍັງບໍ່ມີ terminal ທີ່ເຊື່ອມຕໍ່.', + sessionReconnecting: 'ການເຊື່ອມຕໍ່ຂາດ, ກຳລັງເຊື່ອມຕໍ່ຄືນ...', + sessionExpired: 'ເຊສຊັນບໍ່ສາມາດໃຊ້ໄດ້ອີກ, ກົດ Enter ຫຼືຄລິກເຊື່ອມຕໍ່ຄືນເພື່ອເປີດເຊສຊັນໃໝ່', + sessionKicked: 'ເຊສຊັນນີ້ຖືກເປີດຢູ່ໃນໜ້າຕ່າງອື່ນ', + sessionCount: '{0} ເຊສຊັນ', + minimize: 'ຫຍໍ້ລົງ', + closeAllSessions: 'ປິດທຸກເຊສຊັນ', + closeAllConfirm: 'ເຊສຊັນເທີມິນອລທັງໝົດຈະຖືກຕັດການເຊື່ອມຕໍ່ ແລະ ບໍ່ສາມາດກູ້ຄືນໄດ້. ສືບຕໍ່ບໍ?', lineHeight: 'ຄວາມສູງຂອງແຖວ', letterSpacing: 'ໄລຍະຫ່າງຕົວອັກສອນ', fontSize: 'ຂະໜາດຕົວອັກສອນ', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 30c2d2f2abc8..6597a6f02f22 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -2132,6 +2132,13 @@ const message = { key: 'Kunci peribadi', keyPassword: 'Kata laluan kunci peribadi', emptyTerminal: 'Tiada terminal yang sedang disambungkan.', + sessionReconnecting: 'Sambungan terputus, menyambung semula...', + sessionExpired: 'Sesi tidak lagi tersedia, tekan Enter atau klik sambung semula untuk membuka sesi baharu', + sessionKicked: 'Sesi ini telah dibuka di tetingkap lain', + sessionCount: '{0} sesi', + minimize: 'Minimumkan', + closeAllSessions: 'Tutup semua sesi', + closeAllConfirm: 'Semua sesi terminal akan diputuskan dan tidak boleh dipulihkan. Teruskan?', lineHeight: 'Ketinggian baris', letterSpacing: 'Jarak huruf', fontSize: 'Saiz fon', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 8299e6447836..13b805fc601b 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -2139,6 +2139,14 @@ const message = { key: 'Chave privada', keyPassword: 'Senha da chave privada', emptyTerminal: 'Nenhum terminal está conectado no momento.', + sessionReconnecting: 'Conexão perdida, reconectando...', + sessionExpired: + 'A sessão não está mais disponível, pressione Enter ou clique em reconectar para abrir uma nova', + sessionKicked: 'Esta sessão foi aberta em outra janela', + sessionCount: '{0} sessões', + minimize: 'Minimizar', + closeAllSessions: 'Fechar todas as sessões', + closeAllConfirm: 'Todas as sessões de terminal serão desconectadas e não poderão ser recuperadas. Continuar?', lineHeight: 'Altura da linha', letterSpacing: 'Espaçamento entre letras', fontSize: 'Tamanho da fonte', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index f3ce4a0ed61b..8f33fec4edb6 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -2119,6 +2119,13 @@ const message = { key: 'Приватный ключ', keyPassword: 'Пароль приватного ключа', emptyTerminal: 'В настоящее время нет подключенных терминалов.', + sessionReconnecting: 'Соединение потеряно, переподключение...', + sessionExpired: 'Сессия больше недоступна, нажмите Enter или «Переподключить», чтобы открыть новую', + sessionKicked: 'Эта сессия была открыта в другом окне', + sessionCount: 'сессий: {0}', + minimize: 'Свернуть', + closeAllSessions: 'Закрыть все сессии', + closeAllConfirm: 'Все сессии терминала будут отключены без возможности восстановления. Продолжить?', lineHeight: 'Высота строки', letterSpacing: 'Межбуквенный интервал', fontSize: 'Размер шрифта', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index cff6b9359293..45bea43afac0 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -2125,6 +2125,14 @@ const message = { key: 'Özel anahtar', keyPassword: 'Özel anahtar şifresi', emptyTerminal: 'Şu anda bağlı terminal yok.', + sessionReconnecting: 'Bağlantı koptu, yeniden bağlanılıyor...', + sessionExpired: + "Oturum artık kullanılamıyor, yeni bir oturum açmak için Enter'a basın veya yeniden bağlan'a tıklayın", + sessionKicked: 'Bu oturum başka bir pencerede açıldı', + sessionCount: '{0} oturum', + minimize: 'Küçült', + closeAllSessions: 'Tüm oturumları kapat', + closeAllConfirm: 'Tüm terminal oturumları kesilecek ve geri alınamayacak. Devam edilsin mi?', lineHeight: 'Satır Yüksekliği', letterSpacing: 'Harf Aralığı', fontSize: 'Font Boyutu', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 7262b2c53cba..c1becc09aee9 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1987,6 +1987,13 @@ const message = { key: '私鑰', keyPassword: '私鑰密碼', emptyTerminal: '暫無終端連接', + sessionReconnecting: '連線已中斷,正在重新連線...', + sessionExpired: '會話已失效,按 Enter 或點擊重新連線以新建會話', + sessionKicked: '該會話已在其他視窗開啟', + sessionCount: '{0} 會話', + minimize: '最小化', + closeAllSessions: '關閉所有會話', + closeAllConfirm: '將斷開全部終端會話且無法恢復,是否繼續?', lineHeight: '字體行高', letterSpacing: '字體間距', fontSize: '字體大小', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index d170513d7700..94b17fda79d6 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -2017,6 +2017,13 @@ const message = { key: '私钥', keyPassword: '私钥密码', emptyTerminal: '暂无终端连接', + sessionReconnecting: '连接已断开,正在重连...', + sessionExpired: '会话已失效,按回车或点击重连以新建会话', + sessionKicked: '该会话已在其他窗口打开', + sessionCount: '{0} 会话', + minimize: '最小化', + closeAllSessions: '关闭所有会话', + closeAllConfirm: '将断开全部终端会话且无法恢复,是否继续?', lineHeight: '字体行高', letterSpacing: '字体间距', fontSize: '字体大小', diff --git a/frontend/src/layout/components/Tabs/components/TabItem.vue b/frontend/src/layout/components/Tabs/components/TabItem.vue index 63eb62b88d61..3129e02fe53d 100644 --- a/frontend/src/layout/components/Tabs/components/TabItem.vue +++ b/frontend/src/layout/components/Tabs/components/TabItem.vue @@ -74,11 +74,12 @@