From 9f7887ca3d6bf2a0d94842b6673f4b02e718da30 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:26:11 +0800 Subject: [PATCH] feat(terminal): pin terminal tabs and restore sessions on return --- agent/app/api/v2/alert.go | 13 +- agent/app/api/v2/entry.go | 2 + agent/app/api/v2/terminal.go | 176 +++++++- agent/app/dto/setting.go | 6 +- agent/app/dto/terminal.go | 28 ++ agent/app/repo/setting.go | 15 + agent/app/service/terminal_session.go | 124 ++++++ agent/i18n/lang/en.yaml | 3 + agent/i18n/lang/es-ES.yaml | 3 + agent/i18n/lang/fa.yaml | 3 + agent/i18n/lang/ja.yaml | 3 + agent/i18n/lang/ko.yaml | 3 + agent/i18n/lang/lo.yaml | 3 + agent/i18n/lang/ms.yaml | 3 + agent/i18n/lang/pt-BR.yaml | 3 + agent/i18n/lang/ru.yaml | 3 + agent/i18n/lang/tr.yaml | 3 + agent/i18n/lang/zh-Hant.yaml | 3 + agent/i18n/lang/zh.yaml | 3 + agent/init/migration/migrate.go | 1 + agent/init/migration/migrations/init.go | 13 + agent/router/ro_host.go | 3 + agent/utils/terminal/ai/config_runtime.go | 3 + agent/utils/terminal/attachment.go | 157 +++++++ agent/utils/terminal/manager.go | 311 +++++++++++++ agent/utils/terminal/ringbuf.go | 98 +++++ agent/utils/terminal/session.go | 410 ++++++++++++++++++ agent/utils/terminal/ssh_backend.go | 85 ++++ agent/utils/terminal/ws_msg.go | 54 +++ agent/utils/terminal/ws_session.go | 307 ------------- frontend/src/api/interface/setting.ts | 4 + frontend/src/api/interface/terminal.ts | 13 + frontend/src/api/modules/terminal.ts | 10 + frontend/src/components/terminal/index.vue | 41 +- frontend/src/lang/modules/en.ts | 13 + frontend/src/lang/modules/es-es.ts | 14 + frontend/src/lang/modules/fa.ts | 12 + frontend/src/lang/modules/ja.ts | 14 + frontend/src/lang/modules/ko.ts | 12 + frontend/src/lang/modules/lo.ts | 12 + frontend/src/lang/modules/ms.ts | 13 + frontend/src/lang/modules/pt-br.ts | 13 + frontend/src/lang/modules/ru.ts | 14 + frontend/src/lang/modules/tr.ts | 13 + frontend/src/lang/modules/zh-Hant.ts | 11 + frontend/src/lang/modules/zh.ts | 11 + frontend/src/views/terminal/setting/index.vue | 88 +++- .../src/views/terminal/terminal/index.vue | 248 ++++++++++- 48 files changed, 2043 insertions(+), 355 deletions(-) create mode 100644 agent/app/dto/terminal.go create mode 100644 agent/app/service/terminal_session.go create mode 100644 agent/utils/terminal/attachment.go create mode 100644 agent/utils/terminal/manager.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 diff --git a/agent/app/api/v2/alert.go b/agent/app/api/v2/alert.go index 90c4480bee7c..d7ff27397643 100644 --- a/agent/app/api/v2/alert.go +++ b/agent/app/api/v2/alert.go @@ -300,10 +300,12 @@ func (b *BaseApi) UpdateAlertConfig(c *gin.Context) { helper.Success(c) } -func loadAuditUser(c *gin.Context) string { +// panelUser is the panel user the core proxy identified via the X-Panel-User +// header, or "" when the caller is unknown. +func panelUser(c *gin.Context) string { userName := strings.TrimSpace(c.GetHeader("X-Panel-User")) if userName == "" { - return defaultAuditUser + return "" } if decoded, err := url.QueryUnescape(userName); err == nil { return decoded @@ -311,6 +313,13 @@ func loadAuditUser(c *gin.Context) string { return userName } +func loadAuditUser(c *gin.Context) string { + if user := panelUser(c); user != "" { + return user + } + return defaultAuditUser +} + // @Tags Alert // @Summary Delete alert config // @Accept json diff --git a/agent/app/api/v2/entry.go b/agent/app/api/v2/entry.go index 2bcdc3ab4366..ac2235235f21 100644 --- a/agent/app/api/v2/entry.go +++ b/agent/app/api/v2/entry.go @@ -85,4 +85,6 @@ var ( alertService = service.NewIAlertService() diskService = service.NewIDiskService() + + terminalSessionService = service.NewITerminalSessionService() ) diff --git a/agent/app/api/v2/terminal.go b/agent/app/api/v2/terminal.go index 0260cb1f1242..ea6a9b3c801d 100644 --- a/agent/app/api/v2/terminal.go +++ b/agent/app/api/v2/terminal.go @@ -6,7 +6,9 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" + "unicode" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/dto" @@ -21,6 +23,12 @@ import ( "github.com/pkg/errors" ) +// closeCodeSessionNotFound is sent when the session is gone or not owned by the caller. +const closeCodeSessionNotFound = 4404 + +// maxTerminalTitleRunes caps the client supplied tab name. +const maxTerminalTitleRunes = 64 + // @Tags Terminal // @Summary Ws local terminal // @Param command query string false "command" @@ -29,7 +37,11 @@ import ( // @Security Timestamp // @Router /hosts/terminal/local [get] func (b *BaseApi) WsLocalTerminal(c *gin.Context) { - b.runSSHSession(c, loadLocalConn, c.DefaultQuery("command", "")) + b.runSSHSession(c, sshSessionOption{ + kind: terminal.SessionKindLocal, + connect: loadLocalConn, + command: c.DefaultQuery("command", ""), + }) } // @Tags Terminal @@ -41,14 +53,19 @@ func (b *BaseApi) WsLocalTerminal(c *gin.Context) { // @Security Timestamp // @Router /hosts/terminal/ssh [get] func (b *BaseApi) WsHostSSH(c *gin.Context) { - b.runSSHSession(c, func() (*ssh.SSHClient, error) { - hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0")) - if hostID <= 0 { - return nil, errors.New("missing host id") - } - host, err := service.GetHostInfo(uint(hostID)) - return newHostSSHClient(host, err) - }, c.DefaultQuery("command", "")) + hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0")) + b.runSSHSession(c, sshSessionOption{ + kind: terminal.SessionKindSSH, + hostID: uint(max(hostID, 0)), + connect: func() (*ssh.SSHClient, error) { + if hostID <= 0 { + return nil, errors.New("missing host id") + } + host, err := service.GetHostInfo(uint(hostID)) + return newHostSSHClient(host, err) + }, + command: c.DefaultQuery("command", ""), + }) } // @Tags Terminal @@ -115,39 +132,160 @@ func prepareTerminalSession(c *gin.Context) (*websocket.Conn, int, int, bool) { return wsConn, cols, rows, true } -func (b *BaseApi) runSSHSession(c *gin.Context, connect func() (*ssh.SSHClient, error), command string) { +type sshSessionOption struct { + kind string + hostID uint + connect func() (*ssh.SSHClient, error) + command string +} + +func (b *BaseApi) runSSHSession(c *gin.Context, opt sshSessionOption) { wsConn, cols, rows, ok := prepareTerminalSession(c) if !ok { return } defer wsConn.Close() - client, clientErr := connect() + owner := panelUser(c) + if sessionID := strings.TrimSpace(c.Query("session")); len(sessionID) != 0 { + attachTerminalSession(wsConn, sessionID, owner, cols, rows) + return + } + + client, clientErr := opt.connect() if wshandleError(wsConn, errors.WithMessage(clientErr, "failed to set up the connection. Please check the host information")) { return } - defer client.Close() - sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, command) - if wshandleError(wsConn, err) { + sess, err := terminal.DefaultManager.OpenSession(client.Client, terminal.SessionOptions{ + Kind: opt.kind, + HostID: opt.hostID, + Title: sanitizeTerminalTitle(c.Query("title")), + Owner: owner, + Cols: cols, + Rows: rows, + InitCmd: opt.command, + }) + if err != nil { + // session does not exist yet, so we still own the ssh client + client.Close() + _ = wshandleError(wsConn, err) return } - defer sws.Close() + // no defer sess.Close(): pinned sessions outlive this websocket + att, err := sess.Attach(wsConn, cols, rows) + if err != nil { + sess.Close() + _ = wshandleError(wsConn, err) + return + } + att.Run() - quitChan := make(chan bool, 3) - sws.Start(quitChan) - go sws.Wait(quitChan) + closeTerminalConn(wsConn) +} - <-quitChan +// attachTerminalSession binds the websocket to an existing session. +func attachTerminalSession(wsConn *websocket.Conn, sessionID, owner string, cols, rows int) { + sess, err := terminal.DefaultManager.Lookup(sessionID, owner) + if err != nil { + closeTerminalConnWithCode(wsConn, closeCodeSessionNotFound, "session not found") + return + } + att, err := sess.Attach(wsConn, cols, rows) + if err != nil { + global.LOG.Errorf("attach terminal session %s failed, err: %v", sessionID, err) + closeTerminalConnWithCode(wsConn, closeCodeSessionNotFound, "session not found") + return + } + att.Run() closeTerminalConn(wsConn) } +// sanitizeTerminalTitle keeps a client supplied tab name printable and short. +func sanitizeTerminalTitle(title string) string { + var builder strings.Builder + count := 0 + for _, r := range strings.TrimSpace(title) { + if unicode.IsControl(r) { + continue + } + if count == maxTerminalTitleRunes { + break + } + builder.WriteRune(r) + count++ + } + return strings.TrimSpace(builder.String()) +} + +// @Tags Terminal +// @Summary Search terminal sessions +// @Accept json +// @Success 200 {array} dto.TerminalSessionInfo +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /hosts/terminal/sessions/search [post] +func (b *BaseApi) SearchTerminalSessions(c *gin.Context) { + list, err := terminalSessionService.List(panelUser(c)) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, list) +} + +// @Tags Terminal +// @Summary Pin or unpin a terminal session +// @Accept json +// @Param request body dto.TerminalSessionPin true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /hosts/terminal/sessions/pin [post] +func (b *BaseApi) PinTerminalSession(c *gin.Context) { + var req dto.TerminalSessionPin + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := terminalSessionService.Pin(panelUser(c), req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(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 + } + if err := terminalSessionService.Close(panelUser(c), req.ID); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + func closeTerminalConn(wsConn *websocket.Conn) { dt := time.Now().Add(time.Second) _ = wsConn.WriteControl(websocket.CloseMessage, nil, dt) } +// closeTerminalConnWithCode reports a terminal specific failure to the client. +func closeTerminalConnWithCode(wsConn *websocket.Conn, code int, reason string) { + dt := time.Now().Add(time.Second) + _ = wsConn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), dt) +} + func newHostSSHClient(host *model.Host, err error) (*ssh.SSHClient, error) { if err != nil { return nil, errors.WithMessage(err, "load host info by id failed") diff --git a/agent/app/dto/setting.go b/agent/app/dto/setting.go index 9f9fa088f3b4..0604854fa157 100644 --- a/agent/app/dto/setting.go +++ b/agent/app/dto/setting.go @@ -27,6 +27,10 @@ type SettingInfo struct { LocalSSHConnShow string `json:"localSSHConnShow"` FirewallPortWhiteList string `json:"firewallPortWhiteList"` + + TerminalSessionKeepAlive string `json:"terminalSessionKeepAlive"` + TerminalSessionMaxPinned string `json:"terminalSessionMaxPinned"` + TerminalSessionBuffer string `json:"terminalSessionBuffer"` } type SettingUpdate struct { @@ -35,7 +39,7 @@ type SettingUpdate struct { } type AgentSettingUpdate struct { - Key string `json:"key" validate:"required,oneof=SystemIP DockerSockPath FileRecycleBin FirewallPortWhiteList"` + Key string `json:"key" validate:"required,oneof=SystemIP DockerSockPath FileRecycleBin FirewallPortWhiteList TerminalSessionKeepAlive TerminalSessionMaxPinned TerminalSessionBuffer"` Value string `json:"value"` } diff --git a/agent/app/dto/terminal.go b/agent/app/dto/terminal.go new file mode 100644 index 000000000000..16cd3e532026 --- /dev/null +++ b/agent/app/dto/terminal.go @@ -0,0 +1,28 @@ +package dto + +import "time" + +// TerminalSessionInfo describes one live web terminal session. +type TerminalSessionInfo struct { + ID string `json:"id"` + Kind string `json:"kind"` + HostID uint `json:"hostId"` + Title string `json:"title"` + Pinned bool `json:"pinned"` + Attached bool `json:"attached"` + CreatedAt time.Time `json:"createdAt"` + LastActiveAt time.Time `json:"lastActiveAt"` + // set only while no websocket is bound + DetachedAt *time.Time `json:"detachedAt,omitempty"` + // set only for a pinned session that is currently detached + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +type TerminalSessionPin struct { + ID string `json:"id" validate:"required"` + Pinned bool `json:"pinned"` +} + +type TerminalSessionClose struct { + ID string `json:"id" validate:"required"` +} diff --git a/agent/app/repo/setting.go b/agent/app/repo/setting.go index 429d787be138..f3c6fba80941 100644 --- a/agent/app/repo/setting.go +++ b/agent/app/repo/setting.go @@ -14,6 +14,7 @@ type ISettingRepo interface { GetList(opts ...DBOption) ([]model.Setting, error) Get(opts ...DBOption) (model.Setting, error) GetValueByKey(key string) (string, error) + GetValuesByKeys(keys []string) (map[string]string, error) Create(key, value string) error Update(key, value string) error WithByKey(key string) DBOption @@ -68,6 +69,20 @@ func (s *SettingRepo) GetValueByKey(key string) (string, error) { return setting.Value, nil } +// GetValuesByKeys fetches several settings in one query. Missing keys are +// simply absent from the result. +func (s *SettingRepo) GetValuesByKeys(keys []string) (map[string]string, error) { + var settings []model.Setting + if err := global.DB.Where("key in (?)", keys).Find(&settings).Error; err != nil { + return nil, err + } + values := make(map[string]string, len(settings)) + for _, setting := range settings { + values[setting.Key] = setting.Value + } + return values, nil +} + func (s *SettingRepo) WithByKey(key string) DBOption { return func(g *gorm.DB) *gorm.DB { return g.Where("key = ?", key) diff --git a/agent/app/service/terminal_session.go b/agent/app/service/terminal_session.go new file mode 100644 index 000000000000..c83cd05348db --- /dev/null +++ b/agent/app/service/terminal_session.go @@ -0,0 +1,124 @@ +package service + +import ( + "errors" + "strconv" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/buserr" + "github.com/1Panel-dev/1Panel/agent/utils/terminal" +) + +const ( + settingTerminalSessionKeepAlive = "TerminalSessionKeepAlive" + settingTerminalSessionMaxPinned = "TerminalSessionMaxPinned" + settingTerminalSessionBuffer = "TerminalSessionBuffer" +) + +// KeepAlive is stored in minutes, Buffer in KB. +const ( + defaultTerminalSessionKeepAlive = 30 + minTerminalSessionKeepAlive = 0 + maxTerminalSessionKeepAlive = 1440 + + defaultTerminalSessionMaxPinned = 10 + minTerminalSessionMaxPinned = 1 + maxTerminalSessionMaxPinned = 50 + + defaultTerminalSessionBuffer = 256 + minTerminalSessionBuffer = 64 + maxTerminalSessionBuffer = 4096 +) + +type TerminalSessionService struct{} + +type ITerminalSessionService interface { + List(owner string) ([]dto.TerminalSessionInfo, error) + Pin(owner string, req dto.TerminalSessionPin) error + Close(owner, id string) error +} + +// NewITerminalSessionService wires the session manager to the settings table. +func NewITerminalSessionService() ITerminalSessionService { + terminal.DefaultManager.SetConfigProvider(loadTerminalSessionConfig) + return &TerminalSessionService{} +} + +func (u *TerminalSessionService) List(owner string) ([]dto.TerminalSessionInfo, error) { + infos := terminal.DefaultManager.List(owner) + + list := make([]dto.TerminalSessionInfo, 0, len(infos)) + for _, info := range infos { + item := dto.TerminalSessionInfo{ + ID: info.ID, + Kind: info.Kind, + HostID: info.HostID, + Title: info.Title, + Pinned: info.Pinned, + Attached: info.Attached, + CreatedAt: info.CreatedAt, + LastActiveAt: info.LastActiveAt, + } + if !info.Attached && !info.DetachedAt.IsZero() { + item.DetachedAt = new(info.DetachedAt) + if info.Pinned && !info.ExpiresAt.IsZero() { + item.ExpiresAt = new(info.ExpiresAt) + } + } + list = append(list, item) + } + return list, nil +} + +func (u *TerminalSessionService) Pin(owner string, req dto.TerminalSessionPin) error { + return terminalSessionErr(terminal.DefaultManager.Pin(strings.TrimSpace(req.ID), owner, req.Pinned)) +} + +func (u *TerminalSessionService) Close(owner, id string) error { + return terminalSessionErr(terminal.DefaultManager.Close(strings.TrimSpace(id), owner)) +} + +// terminalSessionErr translates manager errors into business errors. +func terminalSessionErr(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, terminal.ErrSessionNotFound): + return buserr.New("ErrTerminalSessionNotFound") + case errors.Is(err, terminal.ErrPinDisabled): + return buserr.New("ErrTerminalSessionDisabled") + case errors.Is(err, terminal.ErrPinLimit): + return buserr.WithDetail("ErrTerminalSessionLimit", terminal.DefaultManager.Config().MaxPinned, err) + } + return err +} + +// loadTerminalSessionConfig reads keep-alive settings, clamping to accepted ranges. +func loadTerminalSessionConfig() terminal.Config { + values, err := settingRepo.GetValuesByKeys([]string{ + settingTerminalSessionKeepAlive, + settingTerminalSessionMaxPinned, + settingTerminalSessionBuffer, + }) + if err != nil { + values = nil + } + keepAlive := terminalSessionSetting(values, settingTerminalSessionKeepAlive, defaultTerminalSessionKeepAlive, minTerminalSessionKeepAlive, maxTerminalSessionKeepAlive) + maxPinned := terminalSessionSetting(values, settingTerminalSessionMaxPinned, defaultTerminalSessionMaxPinned, minTerminalSessionMaxPinned, maxTerminalSessionMaxPinned) + buffer := terminalSessionSetting(values, settingTerminalSessionBuffer, defaultTerminalSessionBuffer, minTerminalSessionBuffer, maxTerminalSessionBuffer) + return terminal.Config{ + KeepAlive: time.Duration(keepAlive) * time.Minute, + MaxPinned: maxPinned, + RingSize: buffer * 1024, + } +} + +func terminalSessionSetting(values map[string]string, key string, fallback, lower, upper int) int { + number, err := strconv.Atoi(strings.TrimSpace(values[key])) + if err != nil { + return fallback + } + return min(max(number, lower), upper) +} diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 2a4cf28ddb53..6a377c92f36f 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -71,6 +71,9 @@ Decrypt: "Decrypt" # agent ErrAgentAccountBound: 'Account is bound to an agent and cannot be deleted' ErrTerminalAIAccountInUse: 'This model account is currently used by Terminal AI. Switch the account in Terminal AI settings or disable Terminal AI and try again.' +ErrTerminalSessionNotFound: 'Terminal session does not exist or has expired' +ErrTerminalSessionLimit: 'The number of pinned terminal sessions has reached the limit ({{ .detail }})' +ErrTerminalSessionDisabled: 'Terminal session keep alive is disabled' ErrFileAIAccountInUse: 'This model account is currently used by File Management AI. Switch the account in File Management AI settings or disable File Management AI and try again.' ErrAgentAccountUnavailable: 'Account connection unavailable: {{ .err }}' ErrAgentProviderNotSupported: 'Unsupported agent provider' diff --git a/agent/i18n/lang/es-ES.yaml b/agent/i18n/lang/es-ES.yaml index 7864f424381a..1a3d9a28a85f 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -71,6 +71,9 @@ Decrypt: 'Descifrar' # agente ErrAgentAccountBound: 'Cuenta vinculada a agente' ErrTerminalAIAccountInUse: 'Esta cuenta de modelo esta siendo usada por Terminal AI. Cambia la cuenta en la configuracion de Terminal AI o desactiva Terminal AI y vuelve a intentarlo.' +ErrTerminalSessionNotFound: 'La sesion de terminal no existe o ha expirado' +ErrTerminalSessionLimit: 'Se alcanzo el limite de sesiones de terminal fijadas ({{ .detail }})' +ErrTerminalSessionDisabled: 'El mantenimiento de sesiones de terminal esta desactivado' ErrFileAIAccountInUse: 'Esta cuenta de modelo está siendo utilizada por la IA del gestor de archivos. Cambia la cuenta en su configuración o desactívala y vuelve a intentarlo.' ErrAgentAccountUnavailable: 'Conexión de cuenta no disponible: {{ .err }}' ErrAgentProviderNotSupported: 'Proveedor de agente no soportado' diff --git a/agent/i18n/lang/fa.yaml b/agent/i18n/lang/fa.yaml index b52a0cd15cc6..01e9357d8401 100644 --- a/agent/i18n/lang/fa.yaml +++ b/agent/i18n/lang/fa.yaml @@ -71,6 +71,9 @@ Decrypt: "رمزگشایی" # عامل ErrAgentAccountBound: 'حساب به یک عامل متصل است و قابل حذف نیست' ErrTerminalAIAccountInUse: 'این حساب مدل در حال حاضر توسط ترمینال هوش مصنوعی استفاده می‌شود. حساب را در تنظیمات ترمینال هوش مصنوعی تغییر دهید یا ترمینال هوش مصنوعی را غیرفعال کرده و دوباره تلاش کنید.' +ErrTerminalSessionNotFound: 'نشست ترمینال وجود ندارد یا منقضی شده است' +ErrTerminalSessionLimit: 'تعداد نشست‌های ترمینال سنجاق‌شده به حد مجاز رسیده است ({{ .detail }})' +ErrTerminalSessionDisabled: 'قابلیت نگهداری نشست ترمینال غیرفعال است' ErrFileAIAccountInUse: 'این حساب مدل توسط هوش مصنوعی مدیریت فایل استفاده می‌شود. حساب را در تنظیمات آن تغییر دهید یا هوش مصنوعی مدیریت فایل را غیرفعال کرده و دوباره تلاش کنید.' ErrAgentAccountUnavailable: 'اتصال حساب در دسترس نیست: {{ .err }}' ErrAgentProviderNotSupported: 'ارائه‌دهنده عامل پشتیبانی نمی‌شود' diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index cd322dd09694..339758c97103 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -71,6 +71,9 @@ Decrypt: '復号化' # エージェント ErrAgentAccountBound: 'アカウントはエージェントに紐づいています' ErrTerminalAIAccountInUse: 'このモデルアカウントは Terminal AI で使用中です。Terminal AI の設定で別のアカウントへ切り替えるか、Terminal AI を無効にしてから再試行してください。' +ErrTerminalSessionNotFound: 'ターミナルセッションが存在しないか、有効期限が切れています' +ErrTerminalSessionLimit: '固定したターミナルセッション数が上限に達しました ({{ .detail }})' +ErrTerminalSessionDisabled: 'ターミナルセッションの保持機能は無効です' ErrFileAIAccountInUse: 'このモデルアカウントはファイル管理 AI で使用中です。ファイル管理 AI の設定で別のアカウントへ切り替えるか、ファイル管理 AI を無効にしてから再試行してください。' ErrAgentAccountUnavailable: 'アカウント接続不可: {{ .err }}' ErrAgentProviderNotSupported: 'エージェントプロバイダ非対応' diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index e18337dcf413..963821ae8755 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -71,6 +71,9 @@ Decrypt: '복호화' # 에이전트 ErrAgentAccountBound: '계정이 에이전트에 묶여 있습니다' ErrTerminalAIAccountInUse: '이 모델 계정은 현재 터미널 AI에서 사용 중입니다. 터미널 AI 설정에서 다른 계정으로 변경하거나 터미널 AI를 비활성화한 뒤 다시 시도하세요.' +ErrTerminalSessionNotFound: '터미널 세션이 존재하지 않거나 만료되었습니다' +ErrTerminalSessionLimit: '고정된 터미널 세션 수가 상한에 도달했습니다 ({{ .detail }})' +ErrTerminalSessionDisabled: '터미널 세션 유지 기능이 비활성화되어 있습니다' ErrFileAIAccountInUse: '이 모델 계정은 현재 파일 관리 AI에서 사용 중입니다. 파일 관리 AI 설정에서 다른 계정으로 변경하거나 파일 관리 AI를 비활성화한 뒤 다시 시도하세요.' ErrAgentAccountUnavailable: '계정 연결 불가: {{ .err }}' ErrAgentProviderNotSupported: '지원되지 않는 에이전트 공급자' diff --git a/agent/i18n/lang/lo.yaml b/agent/i18n/lang/lo.yaml index a479227591d8..ddf891e1a532 100644 --- a/agent/i18n/lang/lo.yaml +++ b/agent/i18n/lang/lo.yaml @@ -56,6 +56,9 @@ Decrypt: "ຖອດລະຫັດ" #agent ErrAgentAccountBound: 'ບັນຊີຖືກຜູກມັດກັບຕົວແທນ (Agent) ແລະບໍ່ສາມາດລຶບໄດ້' ErrTerminalAIAccountInUse: 'ບັນຊີໂມເດລນີ້ກຳລັງຖືກໃຊ້ໂດຍ Terminal AI. ກະລຸນາປ່ຽນບັນຊີໃນການຕັ້ງຄ່າ Terminal AI ຫຼື ປິດໃຊ້ງານ Terminal AI ແລ້ວລອງໃໝ່.' +ErrTerminalSessionNotFound: 'ບໍ່ພົບເຊດຊັນເທີມິນອລ ຫຼື ໝົດອາຍຸແລ້ວ' +ErrTerminalSessionLimit: 'ຈຳນວນເຊດຊັນເທີມິນອລທີ່ປັກໝຸດໄດ້ຮອດຂີດຈຳກັດແລ້ວ ({{ .detail }})' +ErrTerminalSessionDisabled: 'ຟັງຊັນຮັກສາເຊດຊັນເທີມິນອລຖືກປິດໄວ້' ErrFileAIAccountInUse: 'ບັນຊີໂມເດລນີ້ກຳລັງຖືກໃຊ້ໂດຍ AI ຈັດການໄຟລ໌. ກະລຸນາປ່ຽນບັນຊີໃນການຕັ້ງຄ່າ AI ຈັດການໄຟລ໌ ຫຼື ປິດໃຊ້ງານແລ້ວລອງໃໝ່.' ErrAgentAccountUnavailable: 'ການເຊື່ອມຕໍ່ບັນຊີບໍ່ສາມາດໃຊ້ງານໄດ້: {{ .err }}' ErrAgentProviderNotSupported: 'ບໍ່ຮອງຮັບຜູ້ໃຫ້ບໍລິການຕົວແທນນີ້' diff --git a/agent/i18n/lang/ms.yaml b/agent/i18n/lang/ms.yaml index cab98f023afc..6f824e5fd6a1 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -71,6 +71,9 @@ Decrypt: 'Dekripsi' # ejen ErrAgentAccountBound: 'Akaun terikat kepada ejen' ErrTerminalAIAccountInUse: 'Akaun model ini sedang digunakan oleh Terminal AI. Tukar akaun dalam tetapan Terminal AI atau nyahaktifkan Terminal AI, kemudian cuba lagi.' +ErrTerminalSessionNotFound: 'Sesi terminal tidak wujud atau telah tamat tempoh' +ErrTerminalSessionLimit: 'Bilangan sesi terminal yang disematkan telah mencapai had ({{ .detail }})' +ErrTerminalSessionDisabled: 'Fungsi pengekalan sesi terminal dimatikan' ErrFileAIAccountInUse: 'Akaun model ini sedang digunakan oleh AI pengurusan fail. Tukar akaun dalam tetapannya atau nyahaktifkan AI tersebut, kemudian cuba lagi.' ErrAgentAccountUnavailable: 'Sambungan akaun tidak tersedia: {{ .err }}' ErrAgentProviderNotSupported: 'Penyedia ejen tidak disokong' diff --git a/agent/i18n/lang/pt-BR.yaml b/agent/i18n/lang/pt-BR.yaml index 06fd85d4ba9d..107906645b94 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -71,6 +71,9 @@ Decrypt: 'Descriptografar' # agente ErrAgentAccountBound: 'Conta vinculada a agente' ErrTerminalAIAccountInUse: 'Esta conta de modelo esta sendo usada pelo Terminal AI. Troque a conta nas configuracoes do Terminal AI ou desative o Terminal AI e tente novamente.' +ErrTerminalSessionNotFound: 'A sessao de terminal nao existe ou expirou' +ErrTerminalSessionLimit: 'O numero de sessoes de terminal fixadas atingiu o limite ({{ .detail }})' +ErrTerminalSessionDisabled: 'A manutencao de sessoes de terminal esta desativada' ErrFileAIAccountInUse: 'Esta conta de modelo está sendo usada pela IA do gerenciador de arquivos. Troque a conta nas configurações ou desative a IA e tente novamente.' ErrAgentAccountUnavailable: 'Conexão da conta indisponível: {{ .err }}' ErrAgentProviderNotSupported: 'Provedor de agente não suportado' diff --git a/agent/i18n/lang/ru.yaml b/agent/i18n/lang/ru.yaml index 85d0a97e9e44..063e07feb272 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -71,6 +71,9 @@ Decrypt: 'Расшифровать' # агент ErrAgentAccountBound: 'Акаунт привязан к агенту' ErrTerminalAIAccountInUse: 'Эта учетная запись модели сейчас используется Terminal AI. Смените учетную запись в настройках Terminal AI или отключите Terminal AI и повторите попытку.' +ErrTerminalSessionNotFound: 'Сессия терминала не существует или истекла' +ErrTerminalSessionLimit: 'Достигнут предел количества закрепленных сессий терминала ({{ .detail }})' +ErrTerminalSessionDisabled: 'Сохранение сессий терминала отключено' ErrFileAIAccountInUse: 'Эта учетная запись модели используется ИИ файлового менеджера. Смените учетную запись в его настройках или отключите ИИ и повторите попытку.' ErrAgentAccountUnavailable: 'Связь с аккаунтом недоступна: {{ .err }}' ErrAgentProviderNotSupported: 'Провайдер агента не поддерживается' diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index 580639f92900..e61a085a2c5d 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -71,6 +71,9 @@ Decrypt: 'Şifre Çöz' # ajan ErrAgentAccountBound: 'Hesap bir ajana bağlı' ErrTerminalAIAccountInUse: 'Bu model hesabi su anda Terminal AI tarafindan kullaniliyor. Terminal AI ayarlarinda baska bir hesaba gecin veya Terminal AI yi devre disi birakip tekrar deneyin.' +ErrTerminalSessionNotFound: 'Terminal oturumu bulunamadi veya suresi doldu' +ErrTerminalSessionLimit: 'Sabitlenen terminal oturumu sayisi ust sinira ulasti ({{ .detail }})' +ErrTerminalSessionDisabled: 'Terminal oturumu koruma ozelligi kapali' ErrFileAIAccountInUse: 'Bu model hesabı dosya yönetimi yapay zekası tarafından kullanılıyor. Ayarlardan hesabı değiştirin veya yapay zekayı devre dışı bırakıp yeniden deneyin.' ErrAgentAccountUnavailable: 'Hesap bağlantısı yok: {{ .err }}' ErrAgentProviderNotSupported: 'Ajans sağlayıcısı desteklenmiyor' diff --git a/agent/i18n/lang/zh-Hant.yaml b/agent/i18n/lang/zh-Hant.yaml index e1003d85b8a8..d9d2ed519c52 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -71,6 +71,9 @@ Decrypt: '解密' # 智慧體 ErrAgentAccountBound: '該帳號已綁定到智能體,無法刪除,請重試。' ErrTerminalAIAccountInUse: '該模型帳號正被終端 AI 使用,請在終端 AI 設定中更換帳號或關閉終端 AI 後再試。' +ErrTerminalSessionNotFound: '終端工作階段不存在或已過期' +ErrTerminalSessionLimit: '釘選的終端工作階段數量已達上限 ({{ .detail }})' +ErrTerminalSessionDisabled: '終端工作階段保持功能已關閉' ErrFileAIAccountInUse: '該模型帳號正被檔案管理 AI 使用,請在檔案管理 AI 設定中更換帳號或關閉檔案管理 AI 後再試。' ErrAgentAccountUnavailable: '帳號連線資訊不可用,錯誤:{{ .err }},請重試' ErrAgentProviderNotSupported: '暫不支援該智能體提供商,請重試' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index b386f4176f30..d4df06440e48 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -71,6 +71,9 @@ Decrypt: "解密" # 智能体 ErrAgentAccountBound: "该账号已绑定智能体,无法删除" ErrTerminalAIAccountInUse: "该模型账号正被终端 AI 使用,请在终端 AI 设置中更换账号或关闭终端 AI 后重试" +ErrTerminalSessionNotFound: "终端会话不存在或已过期" +ErrTerminalSessionLimit: "固定的终端会话数量已达上限 ({{ .detail }})" +ErrTerminalSessionDisabled: "终端会话保持功能已关闭" ErrFileAIAccountInUse: "该模型账号正被文件管理 AI 使用,请在文件管理 AI 设置中更换账号或关闭文件管理 AI 后重试" ErrAgentAccountUnavailable: "账号连接信息不可用: {{ .err }}" ErrAgentProviderNotSupported: "不支持该智能体提供商" diff --git a/agent/init/migration/migrate.go b/agent/init/migration/migrate.go index 000383e0113a..da07a8324da0 100644 --- a/agent/init/migration/migrate.go +++ b/agent/init/migration/migrate.go @@ -108,6 +108,7 @@ func agentDBMigrations() []*gormigrate.Migration { migrations.InitDockerPortGuardStatus, migrations.NormalizeFirewallBackendSelections, migrations.SimplifyFirewallRulePolicy, + migrations.AddTerminalSessionSettings, } } diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index 7d353945c167..525ee8bd8dca 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -1783,3 +1783,16 @@ var SimplifyFirewallRulePolicy = &gormigrate.Migration{ }) }, } + +var AddTerminalSessionSettings = &gormigrate.Migration{ + ID: "20260827-add-terminal-session-settings", + Migrate: func(tx *gorm.DB) error { + if err := tx.Create(&model.Setting{Key: "TerminalSessionKeepAlive", Value: "30"}).Error; err != nil { + return err + } + if err := tx.Create(&model.Setting{Key: "TerminalSessionMaxPinned", Value: "10"}).Error; err != nil { + return err + } + return tx.Create(&model.Setting{Key: "TerminalSessionBuffer", Value: "256"}).Error + }, +} diff --git a/agent/router/ro_host.go b/agent/router/ro_host.go index 8e800628e714..64b931310b78 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/pin", baseApi.PinTerminalSession) + hostRouter.POST("/terminal/sessions/close", baseApi.CloseTerminalSession) hostRouter.GET("/disks", baseApi.GetCompleteDiskInfo) hostRouter.POST("/disks/partition", baseApi.PartitionDisk) diff --git a/agent/utils/terminal/ai/config_runtime.go b/agent/utils/terminal/ai/config_runtime.go index 427141431c43..69461808444c 100644 --- a/agent/utils/terminal/ai/config_runtime.go +++ b/agent/utils/terminal/ai/config_runtime.go @@ -191,6 +191,9 @@ func loadAgentAccount(accountID uint) (*model.AgentAccount, error) { } func loadAgentSettingValue(key string) (string, error) { + if global.DB == nil { + return "", os.ErrNotExist + } var setting model.Setting if err := global.DB.Where("key = ?", key).First(&setting).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/agent/utils/terminal/attachment.go b/agent/utils/terminal/attachment.go new file mode 100644 index 000000000000..7c9caf88601d --- /dev/null +++ b/agent/utils/terminal/attachment.go @@ -0,0 +1,157 @@ +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" +) + +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 + done chan struct{} + closeOnce sync.Once +} + +// Run reads client messages until the websocket fails or this attachment is closed. +func (a *attachment) Run() { + 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.onAttachmentClosed(a) + }() + + for { + select { + case <-a.done: + return + case <-a.sess.done: + return + default: + } + _, wsData, err := a.ws.ReadMessage() + if err != nil { + return + } + 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(websocket.TextMessage, wsData); err != nil { + global.LOG.Errorf("ssh sending heartbeat to webSocket failed, err: %v", err) + } + } + } +} + +// write sends one websocket message, serialized against every other writer. +func (a *attachment) write(msgType int, data []byte) error { + a.writeMu.Lock() + defer a.writeMu.Unlock() + return a.writeLocked(msgType, data) +} + +// writeLocked sends one websocket message, the caller owns writeMu. +func (a *attachment) writeLocked(msgType int, data []byte) error { + select { + case <-a.done: + return errAttachmentClosed + default: + } + return a.ws.WriteMessage(msgType, data) +} + +// close sends a close frame and tears the websocket down. It is 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) + a.writeMu.Lock() + _ = a.ws.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), time.Now().Add(time.Second)) + a.writeMu.Unlock() + _ = a.ws.Close() + }) +} + +func (a *attachment) notifyAIThinking() { + if a == nil { + return + } + 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 a == nil || strings.TrimSpace(message) == "" { + return + } + 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 a == nil || strings.TrimSpace(message) == "" { + return + } + 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 a == 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 a.write(websocket.TextMessage, wsData) +} diff --git a/agent/utils/terminal/manager.go b/agent/utils/terminal/manager.go new file mode 100644 index 000000000000..9a216ba62eb1 --- /dev/null +++ b/agent/utils/terminal/manager.go @@ -0,0 +1,311 @@ +package terminal + +import ( + "errors" + "slices" + "sync" + "time" + + gossh "golang.org/x/crypto/ssh" +) + +var ( + // ErrSessionNotFound covers unknown ids and foreign sessions. + ErrSessionNotFound = errors.New("terminal session not found") + ErrPinDisabled = errors.New("terminal session pinning is disabled") + ErrPinLimit = errors.New("pinned terminal session limit reached") +) + +const ( + DefaultKeepAlive = 30 * time.Minute + DefaultMaxPinned = 10 +) + +const ( + managerInterval = 30 * time.Second + keepaliveTimeout = 10 * time.Second + // grace for sessions that were registered but never attached + unpinnedGrace = time.Minute +) + +// Config is the session lifetime settings. +type Config struct { + // KeepAlive is how long a pinned session survives without a websocket; 0 disables pinning. + KeepAlive time.Duration + MaxPinned int + RingSize int +} + +// Manager owns every live terminal session of this agent. +type Manager struct { + mu sync.Mutex + sessions map[string]*Session + config func() Config + now func() time.Time + + // serializes Pin's quota check-and-set + pinMu sync.Mutex + + startOnce sync.Once +} + +// DefaultManager is the process wide session registry. +var DefaultManager = NewManager() + +// NewManager returns an empty registry; the background loop starts on first Register. +func NewManager() *Manager { + return &Manager{ + sessions: make(map[string]*Session), + now: time.Now, + } +} + +// SetConfigProvider installs the settings callback. It is called lazily. +func (m *Manager) SetConfigProvider(fn func() Config) { + m.mu.Lock() + defer m.mu.Unlock() + m.config = fn +} + +// Config returns current settings, substituting defaults for unusable values. +func (m *Manager) Config() Config { + m.mu.Lock() + fn := m.config + m.mu.Unlock() + + cfg := Config{KeepAlive: DefaultKeepAlive, MaxPinned: DefaultMaxPinned, RingSize: defaultRingSize} + if fn != nil { + cfg = fn() + } + cfg.KeepAlive = max(cfg.KeepAlive, 0) + cfg.MaxPinned = max(cfg.MaxPinned, 0) + if cfg.RingSize <= 0 { + cfg.RingSize = defaultRingSize + } + return cfg +} + +// Register adds s to the registry and makes it remove itself once it closes. +func (m *Manager) Register(s *Session) { + if s == nil { + return + } + s.SetOnClosed(m.remove) + + m.mu.Lock() + m.sessions[s.ID] = s + m.mu.Unlock() + + m.start() + + // close hook may have missed a session that already died + select { + case <-s.Done(): + m.remove(s) + default: + } +} + +// OpenSession creates a session on client and registers it. +func (m *Manager) OpenSession(client *gossh.Client, opts SessionOptions) (*Session, error) { + if opts.RingSize <= 0 { + opts.RingSize = m.Config().RingSize + } + sess, err := NewSession(client, opts) + if err != nil { + return nil, err + } + m.Register(sess) + return sess, nil +} + +// Get returns the session with that id, if it is still alive. +func (m *Manager) Get(id string) (*Session, bool) { + m.mu.Lock() + defer m.mu.Unlock() + sess, ok := m.sessions[id] + return sess, ok +} + +// List returns sessions visible to owner, oldest first. Empty owner sees all. +func (m *Manager) List(owner string) []SessionInfo { + cfg := m.Config() + sessions := m.snapshot() + infos := make([]SessionInfo, 0, len(sessions)) + for _, sess := range sessions { + info := sess.Info() + if !ownerMatches(info.Owner, owner) { + continue + } + info.ExpiresAt = expiresAt(info, cfg) + infos = append(infos, info) + } + slices.SortFunc(infos, func(a, b SessionInfo) int { return a.CreatedAt.Compare(b.CreatedAt) }) + return infos +} + +// Pin pins or unpins a session. Unpinning a detached session closes it. +func (m *Manager) Pin(id, owner string, pinned bool) error { + sess, err := m.Lookup(id, owner) + if err != nil { + return err + } + if !pinned { + sess.SetPinned(false) + return nil + } + + cfg := m.Config() + if cfg.KeepAlive <= 0 { + return ErrPinDisabled + } + m.pinMu.Lock() + defer m.pinMu.Unlock() + if m.pinnedCount(sess) >= cfg.MaxPinned { + return ErrPinLimit + } + sess.SetPinned(true) + return nil +} + +// Close terminates a session owned by owner. +func (m *Manager) Close(id, owner string) error { + sess, err := m.Lookup(id, owner) + if err != nil { + return err + } + sess.Close() + return nil +} + +// ownerMatches is true if either side is empty (unknown) or they are equal. +func ownerMatches(sessionOwner, caller string) bool { + return sessionOwner == "" || caller == "" || sessionOwner == caller +} + +// Lookup resolves an id for one caller, hiding foreign sessions. +func (m *Manager) Lookup(id, owner string) (*Session, error) { + sess, ok := m.Get(id) + if !ok || !ownerMatches(sess.Owner, owner) { + return nil, ErrSessionNotFound + } + return sess, nil +} + +// remove is the session close hook; must not take a session lock or block. +func (m *Manager) remove(s *Session) { + if s == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if current, ok := m.sessions[s.ID]; ok && current == s { + delete(m.sessions, s.ID) + } +} + +// snapshot copies sessions so Close (which calls remove) cannot deadlock on m.mu. +func (m *Manager) snapshot() []*Session { + m.mu.Lock() + defer m.mu.Unlock() + sessions := make([]*Session, 0, len(m.sessions)) + for _, sess := range m.sessions { + sessions = append(sessions, sess) + } + return sessions +} + +// pinnedCount counts the pinned sessions, ignoring exclude. +func (m *Manager) pinnedCount(exclude *Session) int { + count := 0 + for _, sess := range m.snapshot() { + if sess == exclude { + continue + } + if sess.Pinned() { + count++ + } + } + return count +} + +// setNow overrides the clock the reaper reads, for tests. +func (m *Manager) setNow(fn func() time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + m.now = fn +} + +func (m *Manager) timeNow() time.Time { + m.mu.Lock() + fn := m.now + m.mu.Unlock() + if fn == nil { + return time.Now() + } + return fn() +} + +// start launches the background loop exactly once. +func (m *Manager) start() { + m.startOnce.Do(func() { go m.loop() }) +} + +// loop reaps expired sessions and probes the live ones forever. +func (m *Manager) loop() { + for range time.Tick(managerInterval) { + m.reap() + m.keepaliveAll() + } +} + +// reap closes every detached session whose grace period elapsed. +func (m *Manager) reap() { + sessions := m.snapshot() + if len(sessions) == 0 { + return + } + cfg := m.Config() + now := m.timeNow() + for _, sess := range sessions { + if exp := expiresAt(sess.Info(), cfg); !exp.IsZero() && !exp.After(now) { + sess.Close() + } + } +} + +// expiresAt is when a detached session will be reaped, or zero if attached. +// Never-attached sessions measure grace from CreatedAt. +func expiresAt(info SessionInfo, cfg Config) time.Time { + if info.Attached { + return time.Time{} + } + since := info.DetachedAt + if since.IsZero() { + since = info.CreatedAt + } + grace := unpinnedGrace + if info.Pinned { + grace = cfg.KeepAlive + } + return since.Add(grace) +} + +// keepaliveAll probes each session in its own goroutine; a failed probe closes that session. +func (m *Manager) keepaliveAll() { + for _, sess := range m.snapshot() { + go func() { + result := make(chan error, 1) + go func() { result <- sess.keepalive() }() + select { + case err := <-result: + if err != nil { + sess.Close() + } + case <-time.After(keepaliveTimeout): + sess.Close() + case <-sess.Done(): + } + }() + } +} diff --git a/agent/utils/terminal/ringbuf.go b/agent/utils/terminal/ringbuf.go new file mode 100644 index 000000000000..2fc03051d444 --- /dev/null +++ b/agent/utils/terminal/ringbuf.go @@ -0,0 +1,98 @@ +package terminal + +import ( + "bytes" + "sync" +) + +// defaultRingSize is the default capacity of a session output ring buffer. +const defaultRingSize = 256 * 1024 + +// ringBuffer is a fixed-capacity byte ring; writes drop the oldest bytes when full. +type ringBuffer struct { + mu sync.Mutex + buf []byte + start int // index of the oldest byte + size int // number of bytes currently stored + wrapped bool // true once at least one byte has been dropped +} + +func newRingBuffer(capacity int) *ringBuffer { + if capacity <= 0 { + capacity = defaultRingSize + } + return &ringBuffer{buf: make([]byte, capacity)} +} + +// Write appends p, dropping 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 { + if n > capacity || r.size > 0 { + r.wrapped = true + } + copy(r.buf, p[n-capacity:]) + r.start = 0 + r.size = capacity + return n, nil + } + + end := (r.start + r.size) % capacity + written := copy(r.buf[end:], p) + if written < n { + copy(r.buf, p[written:]) + } + if r.size+n > capacity { + r.wrapped = true + r.start = (r.start + (r.size + n - capacity)) % capacity + r.size = capacity + } else { + r.size += n + } + return n, nil +} + +// Snapshot returns buffered bytes, oldest first. +// After overflow, starts after the first '\n' so replay is not mid-escape or mid-rune. +func (r *ringBuffer) Snapshot() []byte { + r.mu.Lock() + defer r.mu.Unlock() + if r.size == 0 { + return nil + } + out := make([]byte, r.size) + n := copy(out, r.buf[r.start:min(r.start+r.size, len(r.buf))]) + if n < r.size { + copy(out[n:], r.buf[:r.size-n]) + } + if !r.wrapped { + return out + } + if idx := bytes.IndexByte(out, '\n'); idx >= 0 { + return out[idx+1:] + } + return out +} + +// Len returns the number of buffered bytes. +func (r *ringBuffer) Len() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.size +} + +// Reset drops every buffered byte. +func (r *ringBuffer) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.start = 0 + r.size = 0 + r.wrapped = false +} diff --git a/agent/utils/terminal/session.go b/agent/utils/terminal/session.go new file mode 100644 index 000000000000..f2ee94e23121 --- /dev/null +++ b/agent/utils/terminal/session.go @@ -0,0 +1,410 @@ +package terminal + +import ( + "bytes" + "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" +) + +// Session kinds handled by this package. +const ( + SessionKindLocal = "local" + SessionKindSSH = "ssh" +) + +// closeCodeAttachedElsewhere is sent when another websocket attaches to this session. +const closeCodeAttachedElsewhere = 4409 + +// comboFlushInterval is the output coalescing interval. +const comboFlushInterval = 60 * time.Millisecond + +// logoutOutput is the shell output that marks a finished login shell. +var logoutOutput = []byte{13, 10, 108, 111, 103, 111, 117, 116, 13, 10} + +var errSessionClosed = errors.New("terminal session is closed") + +// shellBackend is the interactive shell a Session drives. +type shellBackend interface { + Write(p []byte) (int, error) + Resize(cols, rows int) error + Keepalive() error + Wait() error + Close() error +} + +// SessionOptions describes a session that is about to be created. +type SessionOptions struct { + Kind string + HostID uint + Title string + Owner string + Cols int + Rows int + InitCmd string + RingSize int +} + +// SessionInfo is an immutable view of a session state. +type SessionInfo struct { + ID string + Kind string + HostID uint + Title string + Owner string + Pinned bool + Attached bool + CreatedAt time.Time + LastActiveAt time.Time + DetachedAt time.Time + // set by Manager.List for detached sessions; Session.Info leaves it zero + ExpiresAt time.Time +} + +// Session owns one shell; a websocket is only a detachable attachment. +type Session struct { + ID string + Kind string + HostID uint + Title string + Owner string + + mu sync.Mutex + pinned bool + createdAt time.Time + lastActiveAt time.Time + detachedAt time.Time + cols int + rows int + attached *attachment + + backend shellBackend + combo *safeBuffer + ring *ringBuffer + // serializes flushCombo + flushMu sync.Mutex + + lang string + aiInterceptor *aiInputInterceptor + aiVersion uint64 + + done chan struct{} + closeOnce sync.Once + onClosed func(*Session) +} + +// NewSession opens a shell on client and starts buffering its output. +func NewSession(client *gossh.Client, opts SessionOptions) (*Session, error) { + combo := new(safeBuffer) + backend, err := newSSHBackend(client, opts.Cols, opts.Rows, opts.InitCmd, combo) + if err != nil { + return nil, err + } + return newSessionWithBackend(backend, combo, opts), nil +} + +// newSessionWithBackend starts the output pump and shell watcher. +func newSessionWithBackend(backend shellBackend, out *safeBuffer, opts SessionOptions) *Session { + now := time.Now() + lang := i18n.GetLanguageFromDB() + sess := &Session{ + ID: uuid.NewString(), + Kind: opts.Kind, + HostID: opts.HostID, + Title: opts.Title, + Owner: opts.Owner, + + createdAt: now, + lastActiveAt: now, + cols: opts.Cols, + rows: opts.Rows, + + backend: backend, + combo: out, + ring: newRingBuffer(opts.RingSize), + + lang: lang, + aiInterceptor: newAIInputInterceptor("", lang), + aiVersion: terminalai.CurrentTerminalRuntimeVersion(), + + done: make(chan struct{}), + } + go sess.pump() + go sess.waitBackend() + return sess +} + +// Done is closed once the session is terminated. +func (s *Session) Done() <-chan struct{} { + return s.done +} + +// SetOnClosed registers a hook invoked once when the session terminates. +func (s *Session) SetOnClosed(fn func(*Session)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onClosed = fn +} + +// SetPinned marks whether the session survives losing its websocket. +// Unpinning a detached session closes it immediately. +func (s *Session) SetPinned(pinned bool) { + s.mu.Lock() + s.pinned = pinned + detached := s.attached == nil + s.mu.Unlock() + if !pinned && detached { + s.Close() + } +} + +// Pinned reports whether the session survives the loss of its attachment. +func (s *Session) Pinned() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.pinned +} + +// IsAttached reports whether a websocket is currently bound to the session. +func (s *Session) IsAttached() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.attached != nil +} + +// Info returns a snapshot of the session state. +func (s *Session) Info() SessionInfo { + s.mu.Lock() + defer s.mu.Unlock() + return SessionInfo{ + ID: s.ID, + Kind: s.Kind, + HostID: s.HostID, + Title: s.Title, + Owner: s.Owner, + Pinned: s.pinned, + Attached: s.attached != nil, + CreatedAt: s.createdAt, + LastActiveAt: s.lastActiveAt, + DetachedAt: s.detachedAt, + } +} + +// Attach binds ws to the session, kicking any previous attachment. +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 + if cols > 0 { + s.cols = cols + } + if rows > 0 { + s.rows = rows + } + s.detachedAt = time.Time{} + s.lastActiveAt = time.Now() + pinned := s.pinned + newCols, newRows := s.cols, s.rows + // Snapshot under lock so a concurrent flush is either replayed or delivered live, never both. + replay := s.ring.Snapshot() + // Hold writeMu across unlock so hello+replay go out before live output. + 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, Pinned: &pinned}) + if err != nil { + return err + } + if err := att.writeLocked(websocket.TextMessage, hello); err != nil { + return err + } + if len(replay) == 0 { + return nil + } + wsData, err := json.Marshal(WsMsg{Type: WsMsgCmd, Data: base64.StdEncoding.EncodeToString(replay)}) + if err != nil { + return err + } + return att.writeLocked(websocket.TextMessage, wsData) + }() + if err != nil { + att.close(websocket.CloseInternalServerErr, "attach failed") + s.mu.Lock() + if s.attached == att { + s.attached = nil + } + s.mu.Unlock() + return nil, err + } + + if err := s.backend.Resize(newCols, newRows); err != nil { + global.LOG.Errorf("ssh pty change windows size failed, err: %v", err) + } + return att, nil +} + +// onAttachmentClosed is called once the attachment loop returned. +func (s *Session) onAttachmentClosed(a *attachment) { + s.mu.Lock() + if s.attached != a { + s.mu.Unlock() + return + } + s.attached = nil + pinned := s.pinned + s.detachedAt = time.Now() + s.mu.Unlock() + + if !pinned { + s.Close() + } +} + +// Close terminates the shell and any attachment. Idempotent. +func (s *Session) Close() { + s.closeOnce.Do(func() { + close(s.done) + if s.backend != nil { + _ = s.backend.Close() + } + s.mu.Lock() + att := s.attached + s.attached = nil + onClosed := s.onClosed + s.mu.Unlock() + + if att != nil { + att.close(websocket.CloseNormalClosure, "") + } + if onClosed != nil { + onClosed(s) + } + }) +} + +// keepalive probes the shell backend connection. +func (s *Session) keepalive() error { + if s.backend == nil { + return nil + } + return s.backend.Keepalive() +} + +// resize forwards a window size change to the shell. +func (s *Session) resize(cols, rows int) { + s.mu.Lock() + s.cols = cols + s.rows = rows + s.lastActiveAt = time.Now() + 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) { + s.mu.Lock() + s.lastActiveAt = time.Now() + s.mu.Unlock() + 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() + currentVersion := terminalai.CurrentTerminalRuntimeVersion() + if s.aiInterceptor == nil || s.aiVersion != currentVersion { + s.aiVersion = currentVersion + s.aiInterceptor = newAIInputInterceptor("", s.lang) + } + return s.aiInterceptor +} + +// pump periodically flushes coalesced shell output. +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(comboFlushInterval) + defer tick.Stop() + for { + select { + case <-s.done: + return + case <-tick.C: + if bs := s.flushCombo(); bytes.Equal(bs, logoutOutput) { + s.Close() + return + } + } + } +} + +// flushCombo writes combo output into the ring and the current attachment. +func (s *Session) flushCombo() []byte { + s.flushMu.Lock() + defer s.flushMu.Unlock() + bs := s.combo.Take() + if len(bs) == 0 { + return nil + } + s.mu.Lock() + if _, err := s.ring.Write(bs); err != nil { + global.LOG.Errorf("combo output to ring buffer failed, err: %v", err) + } + att := s.attached + s.lastActiveAt = time.Now() + s.mu.Unlock() + if att == nil { + return bs + } + 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) + return bs + } + if err := att.write(websocket.TextMessage, wsData); err != nil { + global.LOG.Errorf("ssh sending combo output to webSocket failed, err: %v", err) + att.close(websocket.CloseInternalServerErr, "write failed") + } + return bs +} + +// waitBackend drains leftover combo after the shell exits, then closes the session. +func (s *Session) waitBackend() { + _ = s.backend.Wait() + s.flushCombo() + s.Close() +} 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..c9e52a156bd8 --- /dev/null +++ b/agent/utils/terminal/ws_msg.go @@ -0,0 +1,54 @@ +package terminal + +import ( + "bytes" + "sync" +) + +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) +} + +// Take atomically copies and clears the buffer so concurrent writes are not lost. +func (w *safeBuffer) Take() []byte { + w.mu.Lock() + defer w.mu.Unlock() + if w.buffer.Len() == 0 { + return nil + } + out := bytes.Clone(w.buffer.Bytes()) + w.buffer.Reset() + return out +} + +const ( + WsMsgCmd = "cmd" + WsMsgResize = "resize" + WsMsgHeartbeat = "heartbeat" + WsMsgAINotice = "ai_notice" + 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,omitempty"` // WsMsgResize + Rows int `json:"rows,omitempty"` // WsMsgResize + Timestamp int `json:"timestamp,omitempty"` // WsMsgHeartbeat + ID string `json:"id,omitempty"` // WsMsgSession + Pinned *bool `json:"pinned,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/frontend/src/api/interface/setting.ts b/frontend/src/api/interface/setting.ts index 2dfe30708c33..68455f56a608 100644 --- a/frontend/src/api/interface/setting.ts +++ b/frontend/src/api/interface/setting.ts @@ -27,6 +27,10 @@ export namespace Setting { fileRecycleBin: string; localSSHConnShow: string; firewallPortWhiteList: string; + + terminalSessionKeepAlive: string; + terminalSessionMaxPinned: string; + terminalSessionBuffer: string; } export interface SettingInfo { systemVersion: string; diff --git a/frontend/src/api/interface/terminal.ts b/frontend/src/api/interface/terminal.ts index 94a6eb39f8f5..5fa304b099a1 100644 --- a/frontend/src/api/interface/terminal.ts +++ b/frontend/src/api/interface/terminal.ts @@ -7,3 +7,16 @@ export interface ReqTerminal { password: string; key: string; } + +export interface TerminalSession { + id: string; + kind: 'local' | 'ssh'; + hostId: number; + title: string; + pinned: boolean; + attached: boolean; + createdAt: string; + lastActiveAt: string; + detachedAt?: string; + expiresAt?: string; +} diff --git a/frontend/src/api/modules/terminal.ts b/frontend/src/api/modules/terminal.ts index 45332343a169..0831371be42a 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,12 @@ export const loadLocalConn = () => { export const testLocalConn = () => { return http.post(`/settings/ssh/check`); }; +export const searchTerminalSessions = (node: string) => { + return http.post>(`/hosts/terminal/sessions/search`, {}, undefined, { CurrentNode: node }); +}; +export const pinTerminalSession = (params: { id: string; pinned: boolean }, node: string) => { + return http.post(`/hosts/terminal/sessions/pin`, params, undefined, { CurrentNode: node }); +}; +export const closeTerminalSession = (id: string, node: string) => { + return http.post(`/hosts/terminal/sessions/close`, { id: id }, undefined, { CurrentNode: node }); +}; diff --git a/frontend/src/components/terminal/index.vue b/frontend/src/components/terminal/index.vue index ac367cf9b874..b9d4d0f0ac68 100644 --- a/frontend/src/components/terminal/index.vue +++ b/frontend/src/components/terminal/index.vue @@ -25,9 +25,12 @@ import { decodeBase64, encodeBase64 } from '@/utils/base64'; import { TerminalStore } from '@/store'; import { MsgError } from '@/utils/message'; import { checkStreamAuth } from '@/utils/stream-auth'; +import i18n from '@/lang'; import { useGlobalStore } from '@/composables/useGlobalStore'; const { currentNode } = useGlobalStore(); +const emit = defineEmits(['session', 'session-expired', 'session-kicked']); + const terminalElement = ref(null); const fitAddon = new FitAddon(); const termReady = ref(false); @@ -42,6 +45,9 @@ const hideInitCmdEcho = ref(false); const initCmdEchoBuffer = ref(''); const waitForPrompt = ref(''); const waitForPromptBuffer = ref(''); +const sessionID = ref(''); +const sessionTitle = ref(''); +const isAttachMode = ref(false); const aiNotice = ref({ visible: false, loading: false, @@ -114,6 +120,8 @@ interface WsProps { error: string; initCmd: string; waitForPrompt?: string; + sessionId?: string; + title?: string; } interface TerminalBufferLine { @@ -128,6 +136,9 @@ const acceptParams = (props: WsProps) => { initCmd.value = props.initCmd || ''; waitForPrompt.value = props.waitForPrompt || ''; waitForPromptBuffer.value = ''; + sessionID.value = props.sessionId || ''; + sessionTitle.value = props.title || ''; + isAttachMode.value = !!props.sessionId; init(props.endpoint, props.args); } }); @@ -258,6 +269,12 @@ 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)}`; + } + if (sessionTitle.value) { + conn += `&title=${encodeURIComponent(sessionTitle.value)}`; + } const authError = await checkStreamAuth(conn); if (token !== initWebSocketToken || !termReady.value) { return; @@ -295,7 +312,7 @@ const showWebSocketAuthError = (message: string) => { const runRealTerminal = () => { webSocketReady.value = true; term.value?.focus(); - if (initCmd.value !== '') { + if (!isAttachMode.value && initCmd.value !== '') { hideInitCmdEcho.value = true; initCmdEchoBuffer.value = ''; sendMsg(initCmd.value); @@ -355,6 +372,11 @@ const onWSReceive = (message: MessageEvent) => { } break; } + case 'session': { + sessionID.value = wsMsg.id || ''; + emit('session', { id: sessionID.value, pinned: !!wsMsg.pinned }); + break; + } case 'heartbeat': { latency.value = new Date().getTime() - wsMsg.timestamp; break; @@ -385,10 +407,26 @@ const closeRealTerminal = (ev: CloseEvent) => { heartbeatTimer.value = undefined; } terminalSocket.value = undefined; + switch (ev.code) { + case 4404: + sessionID.value = ''; + isAttachMode.value = false; + writeSessionNotice(i18n.global.t('terminal.sessionExpired')); + emit('session-expired'); + return; + case 4409: + writeSessionNotice(i18n.global.t('terminal.sessionKicked')); + emit('session-kicked'); + return; + } term.value?.write('The connection has been disconnected.'); term.value?.write(ev.reason); }; +const writeSessionNotice = (message: string) => { + term.value?.write(`\r\n\x1b[31m${message}\x1b[m\r\n`); +}; + const isWsOpen = () => { const readyState = terminalSocket.value && terminalSocket.value.readyState; return readyState === 1; @@ -510,6 +548,7 @@ defineExpose({ isWsOpen, sendMsg, getLatency: () => latency.value, + getSessionId: () => sessionID.value, }); onBeforeUnmount(() => { diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index a2f57767dd99..9b7a27be917e 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -2133,6 +2133,19 @@ const message = { aiPrefixAsciiVisible: 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Are you sure you want to save the current terminal configuration?', + pinSession: 'Pin session', + unpinSession: 'Unpin session', + pinSessionHelper: + 'Once pinned, the session keeps running on the server for {0} minutes after you leave the page or close the browser.', + sessionRestored: '{0} session(s) restored', + sessionExpired: 'The session has expired or no longer exists, please reconnect.', + sessionKicked: 'This session has been opened in another window.', + closePinnedConfirm: 'This terminal is pinned. Closing it will also end the session on the server. Continue?', + sessionKeepAlive: 'Session keep-alive (minutes)', + sessionKeepAliveHelper: + 'How long a pinned terminal session is kept after you leave the page. 0 disables session keep-alive.', + sessionMaxPinned: 'Max pinned sessions', + sessionBuffer: 'Replay buffer (KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index bbc9bce06dbb..82df3da6f7f0 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -2175,6 +2175,20 @@ const message = { aiPrefixAsciiVisible: 'Solo se admiten caracteres ASCII visibles. No se permiten espacios, caracteres CJK ni símbolos de ancho completo.', saveHelper: '¿Está seguro de que desea guardar la configuración actual de la terminal?', + pinSession: 'Fijar sesión', + unpinSession: 'Dejar de fijar la sesión', + pinSessionHelper: + 'Una vez fijada, la sesión sigue ejecutándose en el servidor durante {0} minutos después de que salga de la página o cierre el navegador.', + sessionRestored: 'Se han restaurado {0} sesiones', + sessionExpired: 'La sesión ha caducado o ya no existe, vuelva a conectarse.', + sessionKicked: 'Esta sesión se ha abierto en otra ventana.', + closePinnedConfirm: + 'Esta terminal está fijada. Al cerrarla también se finalizará la sesión en el servidor. ¿Continuar?', + sessionKeepAlive: 'Duración de la sesión (minutos)', + sessionKeepAliveHelper: + 'Tiempo que se mantiene una sesión de terminal fijada después de salir de la página. 0 desactiva esta función.', + sessionMaxPinned: 'Máximo de sesiones fijadas', + sessionBuffer: 'Búfer de reproducción (KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index 018c06b6edfd..a8ddf72e4797 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -2112,6 +2112,18 @@ const message = { aiPrefixAsciiVisible: 'فقط کاراکترهای قابل مشاهده ASCII پشتیبانی می‌شوند. فاصله، کاراکترهای CJK و نمادهای تمام‌عرض مجاز نیستند.', saveHelper: 'آیا مطمئن هستید که می‌خواهید پیکربندی ترمینال فعلی را ذخیره کنید؟', + pinSession: 'سنجاق کردن نشست', + unpinSession: 'برداشتن سنجاق نشست', + pinSessionHelper: 'پس از سنجاق کردن، نشست تا {0} دقیقه پس از ترک صفحه یا بستن مرورگر روی سرور فعال می‌ماند.', + sessionRestored: '{0} نشست بازیابی شد', + sessionExpired: 'نشست منقضی شده یا دیگر وجود ندارد، لطفاً دوباره متصل شوید.', + sessionKicked: 'این نشست در پنجره دیگری باز شده است.', + closePinnedConfirm: 'این ترمینال سنجاق شده است. بستن آن نشست روی سرور را نیز پایان می‌دهد. ادامه می‌دهید؟', + sessionKeepAlive: 'مدت نگهداری نشست (دقیقه)', + sessionKeepAliveHelper: + 'مدت زمانی که نشست ترمینال سنجاق‌شده پس از ترک صفحه نگه داشته می‌شود. مقدار 0 این قابلیت را غیرفعال می‌کند.', + sessionMaxPinned: 'حداکثر نشست‌های سنجاق‌شده', + sessionBuffer: 'بافر بازپخش (کیلوبایت)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 72faf48e3bcb..4ec74ca7100b 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -2122,6 +2122,20 @@ const message = { aiSummary: '行が {0} プレフィックスで始まり Enter を押すと、AI コマンド生成がトリガーされます。', aiPrefixAsciiVisible: 'ASCII の表示可能文字のみ対応しています。スペース、CJK 文字、全角記号は使用できません。', saveHelper: '現在のターミナル設定を保存してもよろしいですか?', + pinSession: 'セッションをピン留め', + unpinSession: 'ピン留めを解除', + pinSessionHelper: + 'ピン留めすると、ページを離れたりブラウザーを閉じたりした後もセッションはサーバー上で {0} 分間保持されます。', + sessionRestored: '{0} 件のセッションを復元しました', + sessionExpired: 'セッションが期限切れか存在しません。再接続してください。', + sessionKicked: 'このセッションは別のウィンドウで開かれています。', + closePinnedConfirm: + 'このターミナルはピン留めされています。閉じるとサーバー上のセッションも終了します。続行しますか?', + sessionKeepAlive: 'セッション保持時間(分)', + sessionKeepAliveHelper: + 'ページを離れた後にピン留めされたセッションを保持する時間です。0 を指定すると機能を無効にします。', + sessionMaxPinned: 'ピン留めセッションの最大数', + sessionBuffer: '再生バッファー(KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 285d1f698a83..868b0b0e6af5 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -2091,6 +2091,18 @@ const message = { aiSummary: '{0} 접두사로 시작하는 줄에서 Enter를 누르면 AI 명령 생성이 트리거됩니다.', aiPrefixAsciiVisible: 'ASCII 표시 가능 문자만 지원합니다. 공백, CJK 문자 및 전각 기호는 사용할 수 없습니다.', saveHelper: '현재 터미널 설정을 저장하시겠습니까?', + pinSession: '세션 고정', + unpinSession: '고정 해제', + pinSessionHelper: '고정하면 페이지를 벗어나거나 브라우저를 닫아도 세션이 서버에서 {0}분 동안 유지됩니다.', + sessionRestored: '{0}개의 세션을 복원했습니다', + sessionExpired: '세션이 만료되었거나 존재하지 않습니다. 다시 연결하세요.', + sessionKicked: '이 세션은 다른 창에서 열렸습니다.', + closePinnedConfirm: '이 터미널은 고정되어 있습니다. 닫으면 서버의 세션도 종료됩니다. 계속하시겠습니까?', + sessionKeepAlive: '세션 유지 시간(분)', + sessionKeepAliveHelper: + '페이지를 벗어난 후 고정된 터미널 세션을 유지하는 시간입니다. 0이면 기능이 비활성화됩니다.', + sessionMaxPinned: '최대 고정 세션 수', + sessionBuffer: '재생 버퍼(KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index 088486172b76..473b5003d365 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -2078,6 +2078,18 @@ const message = { aiPrefixAsciiVisible: 'ຮອງຮັບສະເພາະຕົວອັກສອນ ASCII ທີ່ເຫັນໄດ້ເທົ່ານັ້ນ. ບໍ່ອະນຸຍາດໃຫ້ໃຊ້ຍະຫວ່າງ, ຕົວອັກສອນ CJK, ແລະ ສັນຍະລັກເຕັມຄວາມກວ້າງ.', saveHelper: 'ທ່ານແນ່ໃຈຫຼືບໍ່ວ່າຕ້ອງການບັນທຶກການກຳນົດຄ່າ terminal ນີ້?', + pinSession: 'ປັກໝຸດເຊດຊັນ', + unpinSession: 'ຍົກເລີກການປັກໝຸດ', + pinSessionHelper: + 'ເມື່ອປັກໝຸດແລ້ວ, ເຊດຊັນຈະຍັງເຮັດວຽກຢູ່ເຊີບເວີເປັນເວລາ {0} ນາທີ ຫຼັງຈາກທ່ານອອກຈາກໜ້ານີ້ ຫຼື ປິດບຣາວເຊີ.', + sessionRestored: 'ກູ້ຄືນ {0} ເຊດຊັນແລ້ວ', + sessionExpired: 'ເຊດຊັນໝົດອາຍຸ ຫຼື ບໍ່ມີຢູ່ແລ້ວ, ກະລຸນາເຊື່ອມຕໍ່ໃໝ່.', + sessionKicked: 'ເຊດຊັນນີ້ຖືກເປີດຢູ່ໃນໜ້າຕ່າງອື່ນແລ້ວ.', + closePinnedConfirm: 'ເທີມິນອນນີ້ຖືກປັກໝຸດໄວ້. ການປິດຈະເປັນການສິ້ນສຸດເຊດຊັນຢູ່ເຊີບເວີນຳ. ດຳເນີນຕໍ່ບໍ?', + sessionKeepAlive: 'ໄລຍະເວລາຮັກສາເຊດຊັນ (ນາທີ)', + sessionKeepAliveHelper: 'ໄລຍະເວລາທີ່ເຊດຊັນທີ່ປັກໝຸດຈະຖືກຮັກສາໄວ້ຫຼັງຈາກອອກຈາກໜ້ານີ້. 0 ໝາຍເຖິງປິດຄຸນສົມບັດນີ້.', + sessionMaxPinned: 'ຈຳນວນເຊດຊັນປັກໝຸດສູງສຸດ', + sessionBuffer: 'ບັບເຟີການຫຼິ້ນຄືນ (KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 832822960bff..bf36a9a71d3a 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -2161,6 +2161,19 @@ const message = { aiPrefixAsciiVisible: 'Hanya aksara ASCII yang kelihatan disokong. Ruang, aksara CJK dan simbol lebar penuh tidak dibenarkan.', saveHelper: 'Adakah anda pasti mahu menyimpan konfigurasi terminal semasa?', + pinSession: 'Sematkan sesi', + unpinSession: 'Nyahsemat sesi', + pinSessionHelper: + 'Setelah disemat, sesi akan terus berjalan pada pelayan selama {0} minit selepas anda meninggalkan halaman atau menutup pelayar.', + sessionRestored: '{0} sesi telah dipulihkan', + sessionExpired: 'Sesi telah tamat tempoh atau tidak wujud lagi, sila sambung semula.', + sessionKicked: 'Sesi ini telah dibuka dalam tetingkap lain.', + closePinnedConfirm: 'Terminal ini disemat. Menutupnya juga akan menamatkan sesi pada pelayan. Teruskan?', + sessionKeepAlive: 'Tempoh sesi dikekalkan (minit)', + sessionKeepAliveHelper: + 'Berapa lama sesi terminal yang disemat dikekalkan selepas anda meninggalkan halaman. 0 mematikan ciri ini.', + sessionMaxPinned: 'Sesi disemat maksimum', + sessionBuffer: 'Penimbal main semula (KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index f656dd7e868c..c002a3c28d53 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -2168,6 +2168,19 @@ const message = { aiPrefixAsciiVisible: 'Apenas caracteres ASCII visíveis são suportados. Espaços, caracteres CJK e símbolos de largura total não são permitidos.', saveHelper: 'Tem certeza de que deseja salvar a configuração atual do terminal?', + pinSession: 'Fixar sessão', + unpinSession: 'Desafixar sessão', + pinSessionHelper: + 'Depois de fixada, a sessão continua em execução no servidor por {0} minutos após você sair da página ou fechar o navegador.', + sessionRestored: '{0} sessões restauradas', + sessionExpired: 'A sessão expirou ou não existe mais, conecte-se novamente.', + sessionKicked: 'Esta sessão foi aberta em outra janela.', + closePinnedConfirm: 'Este terminal está fixado. Fechá-lo também encerrará a sessão no servidor. Continuar?', + sessionKeepAlive: 'Duração da sessão (minutos)', + sessionKeepAliveHelper: + 'Por quanto tempo uma sessão de terminal fixada é mantida depois que você sai da página. 0 desativa o recurso.', + sessionMaxPinned: 'Máximo de sessões fixadas', + sessionBuffer: 'Buffer de reprodução (KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index d5197536e0cd..1b680aca4a3a 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -2147,6 +2147,20 @@ const message = { aiPrefixAsciiVisible: 'Поддерживаются только видимые символы ASCII. Пробелы, символы CJK и полноширинные знаки не допускаются.', saveHelper: 'Вы уверены, что хотите сохранить текущую конфигурацию терминала?', + pinSession: 'Закрепить сессию', + unpinSession: 'Открепить сессию', + pinSessionHelper: + 'После закрепления сессия продолжает работать на сервере {0} минут после того, как вы покинете страницу или закроете браузер.', + sessionRestored: 'Восстановлено сессий: {0}', + sessionExpired: 'Сессия истекла или больше не существует, подключитесь заново.', + sessionKicked: 'Эта сессия открыта в другом окне.', + closePinnedConfirm: + 'Этот терминал закреплён. При закрытии сессия на сервере также будет завершена. Продолжить?', + sessionKeepAlive: 'Время хранения сессии (минуты)', + sessionKeepAliveHelper: + 'Сколько времени сохраняется закреплённая сессия терминала после ухода со страницы. 0 отключает эту функцию.', + sessionMaxPinned: 'Максимум закреплённых сессий', + sessionBuffer: 'Буфер воспроизведения (КБ)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 0815350e040f..5f539813dcf2 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -2153,6 +2153,19 @@ const message = { aiPrefixAsciiVisible: 'Yalnızca görünür ASCII karakterleri desteklenir. Boşluklara, CJK karakterlerine ve tam genişlikli sembollere izin verilmez.', saveHelper: 'Mevcut terminal yapılandırmasını kaydetmek istediğinizden emin misiniz?', + pinSession: 'Oturumu sabitle', + unpinSession: 'Sabitlemeyi kaldır', + pinSessionHelper: + 'Sabitlendikten sonra, sayfadan ayrılsanız veya tarayıcıyı kapatsanız bile oturum sunucuda {0} dakika boyunca çalışmaya devam eder.', + sessionRestored: '{0} oturum geri yüklendi', + sessionExpired: 'Oturum süresi doldu veya artık mevcut değil, lütfen yeniden bağlanın.', + sessionKicked: 'Bu oturum başka bir pencerede açıldı.', + closePinnedConfirm: 'Bu terminal sabitlenmiş. Kapatmak sunucudaki oturumu da sonlandırır. Devam edilsin mi?', + sessionKeepAlive: 'Oturum saklama süresi (dakika)', + sessionKeepAliveHelper: + 'Sabitlenmiş bir terminal oturumunun sayfadan ayrıldıktan sonra ne kadar süre saklanacağı. 0 bu özelliği kapatır.', + sessionMaxPinned: 'En fazla sabitlenmiş oturum', + sessionBuffer: 'Yeniden oynatma arabelleği (KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index a6d8295f027a..265198e410e9 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -2011,6 +2011,17 @@ const message = { aiSummary: '以 {0} 前綴開頭並按下 Enter 時,會觸發 AI 命令生成', aiPrefixAsciiVisible: '僅支援 ASCII 可見字元,不支援空格、中文或全形符號', saveHelper: '是否確認儲存目前終端設定?', + pinSession: '釘選工作階段', + unpinSession: '取消釘選', + pinSessionHelper: '釘選後離開頁面或關閉瀏覽器,工作階段仍會在伺服器保持 {0} 分鐘', + sessionRestored: '已復原 {0} 個工作階段', + sessionExpired: '工作階段已過期或不存在,請重新連線', + sessionKicked: '此工作階段已在其他視窗開啟', + closePinnedConfirm: '此終端已釘選,關閉後伺服器端工作階段也會一併結束,是否繼續?', + sessionKeepAlive: '工作階段保持時長(分鐘)', + sessionKeepAliveHelper: '釘選的終端工作階段在離開頁面後可保持的時長,0 表示關閉此功能', + sessionMaxPinned: '最大釘選工作階段數', + sessionBuffer: '回放緩衝大小(KB)', }, toolbox: { common: { diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 21a45b8c19d1..36e10d5480d7 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -2041,6 +2041,17 @@ const message = { aiSummary: '以 {0} 前缀开头并回车时,会触发 AI 命令生成', aiPrefixAsciiVisible: '仅支持 ASCII 可见字符,不支持空格、中文或全角符号', saveHelper: '是否确认保存当前终端配置?', + pinSession: '固定会话', + unpinSession: '取消固定', + pinSessionHelper: '固定后离开页面或关闭浏览器,会话仍会在服务器保持 {0} 分钟', + sessionRestored: '已恢复 {0} 个会话', + sessionExpired: '会话已过期或不存在,请重新连接', + sessionKicked: '该会话已在其他窗口打开', + closePinnedConfirm: '该终端已固定,关闭后服务端会话也将一并结束,是否继续?', + sessionKeepAlive: '会话保持时长(分钟)', + sessionKeepAliveHelper: '固定的终端会话在离开页面后可保持的时长,0 表示关闭会话保持功能', + sessionMaxPinned: '最大固定会话数', + sessionBuffer: '回放缓冲大小(KB)', }, toolbox: { common: { diff --git a/frontend/src/views/terminal/setting/index.vue b/frontend/src/views/terminal/setting/index.vue index 345906f3e934..a114784fc0f7 100644 --- a/frontend/src/views/terminal/setting/index.vue +++ b/frontend/src/views/terminal/setting/index.vue @@ -126,6 +126,42 @@ + + + + + + {{ $t('terminal.sessionKeepAliveHelper') }} + + + + + + + + + + {{ $t('commons.button.save') }} + + @@ -147,7 +183,7 @@