From 81b72d9b7d350599c040beea8eae1d900ea28a54 Mon Sep 17 00:00:00 2001 From: HynoR Date: Mon, 7 Sep 2026 15:01:35 +0800 Subject: [PATCH] feat: Implement server-side SSH session persistence and recovery (#13707) * feat(terminal): keep ssh sessions alive server-side with reattach Split the terminal ws handling into a Session (pty + ssh backend) and an Attachment (one websocket). A session outlives its websocket: a clean close (1000) ends the pty, any other disconnect keeps it for a 30-minute grace period and it can be reattached via `?session=`. Output goes through a fixed 128KB ring buffer so a reattaching client gets the recent tail, with a truncation marker if it fell behind. Sessions are owner-scoped; a second attachment kicks the first (4409), unknown ids get 4404. New endpoints under /hosts/terminal/sessions (search, close) let the frontend list and recover sessions after a tab or browser is closed. * feat(terminal): floating terminal dock with session recovery Terminals now live in a layout-level host and are teleported into whichever view shows them, so leaving the terminal page no longer kills them. A dock handle on the right edge opens a non-modal dialog from any page with every live session, a picker for local shell / ssh hosts, minimize, and close-all. On page load the store recovers sessions the server still holds, so an accidentally closed tab or browser can resume within the grace period. The menu-tab label shows the live session count. * fix(terminal): page re-claims its slots under a locked menu tab With the terminal menu tab locked (keep-alive), leaving the page deactivates it instead of unmounting it, so the slot ref callback never re-runs on return. After the dock had taken the Terminal over and released it, nobody claimed it for the page again and it stayed parked in the hidden host. Claim/release slots explicitly on mount, activated, deactivated and unmount, the same ownership rule the dock uses, instead of relying on the ref callback. * fix(terminal): logout closes every kept-alive terminal session A logged-out panel has nobody watching it, so nothing it left running should survive: core now tells the local agent to close all terminal sessions when the user logs out, changes the password, or changes the bind domain. Until now the teardown relied on the logging-out tab sending close code 1000; a second tab or a websocket held outside the SPA kept its shell after logout. Agent: terminal.CloseAll and POST /hosts/terminal/sessions/closeAll. Core: LogOut / deleteCurrentSession / BindDomain call it via proxy_local, best effort. * fix(terminal): pin a local shell to the node it was opened on The node a local shell connects to was resolved from the current node every time the websocket was built, so after switching nodes a reconnect carried the old session id to the new node (4404) and then opened a shell there instead. Store the operateNode on the entry when it is created; ssh shells keep going to the master. Shells on a non-master node get the node name in their title so a restore in another node's view can tell them apart. --- agent/app/api/v2/terminal.go | 81 +++- agent/app/dto/terminal.go | 5 + agent/i18n/lang/en.yaml | 1 + agent/i18n/lang/es-ES.yaml | 1 + agent/i18n/lang/fa.yaml | 1 + agent/i18n/lang/ja.yaml | 1 + agent/i18n/lang/ko.yaml | 1 + agent/i18n/lang/lo.yaml | 1 + agent/i18n/lang/ms.yaml | 1 + agent/i18n/lang/pt-BR.yaml | 1 + agent/i18n/lang/ru.yaml | 1 + agent/i18n/lang/tr.yaml | 1 + agent/i18n/lang/zh-Hant.yaml | 1 + agent/i18n/lang/zh.yaml | 1 + agent/router/ro_host.go | 3 + agent/utils/terminal/attachment.go | 179 ++++++++ agent/utils/terminal/registry.go | 58 +++ agent/utils/terminal/ringbuf.go | 91 ++++ agent/utils/terminal/session.go | 396 ++++++++++++++++++ agent/utils/terminal/ssh_backend.go | 85 ++++ agent/utils/terminal/ws_msg.go | 26 ++ agent/utils/terminal/ws_session.go | 307 -------------- core/app/service/auth.go | 12 + core/app/service/setting.go | 2 + frontend/src/api/interface/terminal.ts | 9 + frontend/src/api/modules/terminal.ts | 8 + .../src/components/terminal/dock/index.vue | 255 +++++++++++ frontend/src/components/terminal/host.vue | 42 ++ frontend/src/components/terminal/index.vue | 114 ++++- frontend/src/lang/modules/en.ts | 7 + frontend/src/lang/modules/es-es.ts | 7 + frontend/src/lang/modules/fa.ts | 7 + frontend/src/lang/modules/ja.ts | 8 + frontend/src/lang/modules/ko.ts | 7 + frontend/src/lang/modules/lo.ts | 7 + frontend/src/lang/modules/ms.ts | 7 + frontend/src/lang/modules/pt-br.ts | 8 + frontend/src/lang/modules/ru.ts | 7 + frontend/src/lang/modules/tr.ts | 8 + frontend/src/lang/modules/zh-Hant.ts | 7 + frontend/src/lang/modules/zh.ts | 7 + .../components/Tabs/components/TabItem.vue | 7 +- frontend/src/layout/index.vue | 4 + frontend/src/routers/index.ts | 2 + frontend/src/store/index.ts | 3 +- .../src/store/modules/terminal-session.ts | 194 +++++++++ frontend/src/views/terminal/index.vue | 5 +- .../src/views/terminal/terminal/index.vue | 222 +++++----- 48 files changed, 1753 insertions(+), 456 deletions(-) create mode 100644 agent/app/dto/terminal.go create mode 100644 agent/utils/terminal/attachment.go create mode 100644 agent/utils/terminal/registry.go create mode 100644 agent/utils/terminal/ringbuf.go create mode 100644 agent/utils/terminal/session.go create mode 100644 agent/utils/terminal/ssh_backend.go create mode 100644 agent/utils/terminal/ws_msg.go delete mode 100644 agent/utils/terminal/ws_session.go create mode 100644 frontend/src/components/terminal/dock/index.vue create mode 100644 frontend/src/components/terminal/host.vue create mode 100644 frontend/src/store/modules/terminal-session.ts diff --git a/agent/app/api/v2/terminal.go b/agent/app/api/v2/terminal.go index 0260cb1f1..4c25caccc 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")) { + 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) + } +} + +// @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 client.Close() - - sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, command) - if wshandleError(wsConn, err) { + if err := terminal.CloseSession(req.ID, loadAuditUser(c)); err != nil { + helper.BadRequest(c, err) return } - defer sws.Close() + helper.Success(c) +} - quitChan := make(chan bool, 3) - sws.Start(quitChan) - go sws.Wait(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) +} - <-quitChan - - 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 000000000..017c55eb8 --- /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 e41341af0..f2f668982 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -682,6 +682,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 db9841d97..6296a88c7 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -682,6 +682,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 f0df071a1..a100519b2 100644 --- a/agent/i18n/lang/fa.yaml +++ b/agent/i18n/lang/fa.yaml @@ -682,6 +682,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 d6f729740..b41cd3746 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -682,6 +682,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 9c557a048..f39b1734e 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -682,6 +682,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 40233314f..ea90c1d1f 100644 --- a/agent/i18n/lang/lo.yaml +++ b/agent/i18n/lang/lo.yaml @@ -672,6 +672,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 432382b8c..f504feccd 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -682,6 +682,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 1917903db..0cf6425f8 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -682,6 +682,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 09d538e16..3d2a1454b 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -682,6 +682,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 b7d416e39..1e777702d 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -682,6 +682,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 2e6ecd24d..6e0a167a1 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -682,6 +682,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 0ccd06b4c..5c1b1af62 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -682,6 +682,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 8e800628e..5ec475aa2 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 000000000..3b2f764ac --- /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 000000000..8ce8755dc --- /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 000000000..6cfa3c790 --- /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 000000000..c82a9995f --- /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 000000000..9e10e10a0 --- /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 000000000..1a8042498 --- /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 e3c388d6c..000000000 --- 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 a17b913d7..040397182 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 db9d34933..1ee2e9870 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 94a6eb39f..7b9135af1 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 45332343a..f9b1311d4 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 000000000..244996ebc --- /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 000000000..d2044bc3e --- /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 9b6aa1078..7a5ce2c14 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 07a1a2f80..7485dfb90 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -2108,6 +2108,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 67e694662..5a6ec8ec1 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -2149,6 +2149,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 be1571f51..324bada6e 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -2087,6 +2087,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 39ede8043..8e58767aa 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -2098,6 +2098,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 fbbcaccdf..a6fa75c0d 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -2067,6 +2067,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 ba5f9ae69..4758dda61 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -2053,6 +2053,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 6869f6849..e50a8a71a 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -2135,6 +2135,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 b1a04601f..88beacd19 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -2142,6 +2142,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 860ec8aa7..8e97841b9 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -2122,6 +2122,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 ed74a6eed..9e0ab67cb 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -2128,6 +2128,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 40760b899..bd6c3d2c4 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1990,6 +1990,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 60fbb6e59..ebc363809 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -2020,6 +2020,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 63eb62b88..3129e02fe 100644 --- a/frontend/src/layout/components/Tabs/components/TabItem.vue +++ b/frontend/src/layout/components/Tabs/components/TabItem.vue @@ -74,11 +74,12 @@