mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 08:00:53 +00:00
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=<id>`. 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.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package dto
|
||||
|
||||
type TerminalSessionClose struct {
|
||||
ID string `json:"id" validate:"required"`
|
||||
}
|
||||
@@ -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 }}'
|
||||
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
|
||||
@@ -682,6 +682,7 @@ XfsNotFound: 'xfs یافت نشد؛ ابتدا xfsprogs را نصب کنید'
|
||||
# ترمینال
|
||||
TerminalAIBlockedRiskyCommand: 'دستور پرخطر مسدود شد: {{ .command }}'
|
||||
TerminalAIThinking: 'هوش مصنوعی در حال فکر کردن است...'
|
||||
TerminalOutputTruncated: '[خروجی کوتاه شد، فقط خروجی اخیر نمایش داده میشود]'
|
||||
TerminalAIReadyToExecute: 'تفکر کامل شد، برای اجرا Enter را فشار دهید (مدت زمان: {{ .duration }}، توکنها: {{ .tokens }})'
|
||||
TerminalAIRequestFailed: 'درخواست هوش مصنوعی ناموفق بود: {{ .err }}'
|
||||
|
||||
|
||||
@@ -682,6 +682,7 @@ XfsNotFound: 'xfs ファイルシステムが検出されませんでした、
|
||||
# ターミナル
|
||||
TerminalAIBlockedRiskyCommand: '危険なコマンドをブロックしました: {{ .command }}'
|
||||
TerminalAIThinking: 'AI が考えています...'
|
||||
TerminalOutputTruncated: '[出力は切り詰められました。最近の出力のみ表示しています]'
|
||||
TerminalAIReadyToExecute: '思考が完了しました。Enter で実行してください(所要時間 {{ .duration }}、token: {{ .tokens }})'
|
||||
TerminalAIRequestFailed: 'AI リクエストに失敗しました: {{ .err }}'
|
||||
|
||||
|
||||
@@ -682,6 +682,7 @@ XfsNotFound: 'xfs 파일 시스템이 감지되지 않았습니다, 먼저 xfspr
|
||||
# 터미널
|
||||
TerminalAIBlockedRiskyCommand: '위험한 명령이 차단되었습니다: {{ .command }}'
|
||||
TerminalAIThinking: 'AI가 생각 중입니다...'
|
||||
TerminalOutputTruncated: '[출력이 잘렸습니다. 최근 출력만 표시합니다]'
|
||||
TerminalAIReadyToExecute: '생각이 완료되었습니다. Enter를 눌러 실행하세요 (소요 시간 {{ .duration }}, token: {{ .tokens }})'
|
||||
TerminalAIRequestFailed: 'AI 요청 실패: {{ .err }}'
|
||||
|
||||
|
||||
@@ -672,6 +672,7 @@ XfsNotFound: 'ບໍ່ພົບ xfs; ກະລຸນາຕິດຕັ້ງ xf
|
||||
# terminal
|
||||
TerminalAIBlockedRiskyCommand: 'ບລັອກຄຳສັ່ງທີ່ມີຄວາມສ່ຽງ: {{ .command }}'
|
||||
TerminalAIThinking: 'AI ກຳລັງຄິດ...'
|
||||
TerminalOutputTruncated: '[ຜົນລັບຖືກຕັດ, ສະແດງສະເພາະຜົນລັບຫຼ້າສຸດ]'
|
||||
TerminalAIReadyToExecute: 'ຄິດສຳເລັດແລ້ວ, ກົດ Enter ເພື່ອປະຕິບັດ (ໃຊ້ເວລາ: {{ .duration }}, ໂທເຄັນ: {{ .tokens }})'
|
||||
TerminalAIRequestFailed: 'ຄຳຮ້ອງຂໍ AI ລົ້ມເຫຼວ: {{ .err }}'
|
||||
FileAISearchEmptyDir: 'ບໍ່ພົບໄຟລ໌ ຫຼື ໂຟນເດີພາຍໃຕ້ເສັ້ນທາງນີ້ (ຫຼື ລາຍການທັງໝົດຖືກກັ່ນກອງອອກ).'
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
|
||||
@@ -682,6 +682,7 @@ XfsNotFound: 'Файловая система xfs не обнаружена, с
|
||||
# терминал
|
||||
TerminalAIBlockedRiskyCommand: 'Опасная команда заблокирована: {{ .command }}'
|
||||
TerminalAIThinking: 'AI думает...'
|
||||
TerminalOutputTruncated: '[вывод усечён, показан только недавний вывод]'
|
||||
TerminalAIReadyToExecute: 'Обдумывание завершено, нажмите Enter для выполнения (время: {{ .duration }}, token: {{ .tokens }})'
|
||||
TerminalAIRequestFailed: 'Ошибка запроса AI: {{ .err }}'
|
||||
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
|
||||
@@ -682,6 +682,7 @@ XfsNotFound: '未偵測到 xfs 檔案系統,請先安裝 xfsprogs'
|
||||
# 終端
|
||||
TerminalAIBlockedRiskyCommand: '已攔截風險命令:{{ .command }}'
|
||||
TerminalAIThinking: 'AI 正在思考...'
|
||||
TerminalOutputTruncated: '[輸出已截斷,僅顯示最近輸出]'
|
||||
TerminalAIReadyToExecute: '思考完成,請按 Enter 執行(耗時 {{ .duration }},token:{{ .tokens }})'
|
||||
TerminalAIRequestFailed: 'AI 請求失敗:{{ .err }}'
|
||||
|
||||
|
||||
@@ -682,6 +682,7 @@ XfsNotFound: "未检测到 xfs 文件系统,请先安装 xfsprogs"
|
||||
# 终端
|
||||
TerminalAIBlockedRiskyCommand: "已拦截风险命令:{{ .command }}"
|
||||
TerminalAIThinking: "AI 正在思考..."
|
||||
TerminalOutputTruncated: "[输出已截断,仅显示最近输出]"
|
||||
TerminalAIReadyToExecute: "思考完成,请回车执行(耗时 {{ .duration }},token:{{ .tokens }})"
|
||||
TerminalAIRequestFailed: "AI 请求失败:{{ .err }}"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<boolean>(`/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<TerminalSession[]>(`/hosts/terminal/sessions/search`)
|
||||
: http.post<TerminalSession[]>(`/hosts/terminal/sessions/search`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<!-- Right edge handle: open a terminal from any page without leaving it. Hidden on the terminal page itself. -->
|
||||
<div v-if="!onTerminalPage" class="terminal-dock-handle" @click="show">
|
||||
<el-badge :value="store.entries.length" :hidden="store.entries.length === 0" type="primary">
|
||||
<svg-icon iconName="p-terminal2" class="terminal-dock-icon" />
|
||||
</el-badge>
|
||||
<span class="terminal-dock-label">{{ $t('menu.terminal') }}</span>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="open"
|
||||
:title="$t('menu.terminal')"
|
||||
width="70%"
|
||||
draggable
|
||||
:close-on-click-modal="false"
|
||||
:modal="false"
|
||||
:show-close="false"
|
||||
class="terminal-dock-dialog"
|
||||
@closed="park"
|
||||
>
|
||||
<!-- minimize keeps sessions alive; X closes them all (with confirm) -->
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<span class="el-dialog__title flex-1">{{ $t('menu.terminal') }}</span>
|
||||
<el-tooltip :content="$t('terminal.minimize')" placement="top">
|
||||
<el-button link icon="Minus" @click="open = false" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="$t('terminal.closeAllSessions')" placement="top">
|
||||
<el-button link icon="Close" @click="closeAll" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex items-center gap-1 mb-1">
|
||||
<el-tabs v-model="active" type="card" closable class="flex-1 terminal-dock-tabs" @tab-remove="store.remove">
|
||||
<el-tab-pane v-for="item in store.entries" :key="item.key" :name="item.key">
|
||||
<template #label>
|
||||
<span :style="{ color: item.status === 'online' ? '#69db7c' : '#d9480f' }">●</span>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<!-- same picker as the terminal page: local shell + ssh host tree -->
|
||||
<el-popover trigger="click" width="280px" @before-enter="loadHosts">
|
||||
<template #reference>
|
||||
<el-button icon="Plus" circle size="small" />
|
||||
</template>
|
||||
<el-button link class="w-full" @click="connect(0, $t('terminal.localhost'))">
|
||||
<el-icon class="mr-1"><House /></el-icon>
|
||||
{{ $t('terminal.localhost') }}
|
||||
</el-button>
|
||||
<template v-if="!isNodeAdmin">
|
||||
<el-divider class="my-1" />
|
||||
<el-input
|
||||
v-model="hostFilter"
|
||||
size="small"
|
||||
clearable
|
||||
:placeholder="$t('commons.button.search')"
|
||||
class="mb-1"
|
||||
/>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
:data="hostTree"
|
||||
:filter-node-method="filterHost"
|
||||
:empty-text="$t('terminal.noHost')"
|
||||
class="terminal-dock-tree"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span v-if="node.level === 1" class="text-xs font-medium">
|
||||
{{ node.label === 'Default' ? $t('commons.table.default') : node.label }}
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
class="text-xs hover:text-[var(--el-color-primary)] truncate"
|
||||
:title="node.label"
|
||||
@click="connect(data.id, node.label)"
|
||||
>
|
||||
{{ node.label }}
|
||||
</a>
|
||||
</template>
|
||||
</el-tree>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
<div v-if="store.entries.length === 0" class="terminal-dock-empty">{{ $t('terminal.emptyTerminal') }}</div>
|
||||
<!-- one slot per entry; the active one claims its Terminal from the host, the rest stay parked -->
|
||||
<div
|
||||
v-for="item in store.entries"
|
||||
v-show="item.key === active"
|
||||
:key="item.key"
|
||||
class="terminal-dock-slot"
|
||||
:ref="(el: any) => onSlot(item.key, el)"
|
||||
@click="store.instances[item.key]?.refit()"
|
||||
></div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import i18n from '@/lang';
|
||||
import { ElTree } from 'element-plus';
|
||||
import { TerminalSessionStore } from '@/store';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import { getHostTree, testByID, testLocalConn } from '@/api/modules/terminal';
|
||||
import { MsgError } from '@/utils/message';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { Host } from '@/api/interface/host';
|
||||
|
||||
const store = TerminalSessionStore();
|
||||
const { isNodeAdmin } = useGlobalStore();
|
||||
const route = useRoute();
|
||||
const onTerminalPage = computed(() => route.path.startsWith('/terminal'));
|
||||
|
||||
const open = ref(false);
|
||||
const active = ref('');
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const show = async () => {
|
||||
if (!store.find(active.value)) active.value = store.entries[0]?.key || '';
|
||||
open.value = true;
|
||||
await nextTick();
|
||||
claim();
|
||||
store.sync();
|
||||
timer = setInterval(store.sync, 5000);
|
||||
};
|
||||
|
||||
// park releases every slot so the Terminals go back to the off-screen host.
|
||||
const park = () => {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
claim();
|
||||
};
|
||||
|
||||
// The dialog keeps its content mounted while hidden, so slots are claimed explicitly:
|
||||
// only the visible pane owns its Terminal (claiming a hidden one would fit it to 0x0).
|
||||
const slotEls: Record<string, HTMLElement> = {};
|
||||
const onSlot = (key: string, el: HTMLElement | null) => {
|
||||
if (el) slotEls[key] = el;
|
||||
else delete slotEls[key];
|
||||
};
|
||||
// Slots not ours (the terminal page's) are left alone.
|
||||
const claim = () => {
|
||||
for (const item of store.entries) {
|
||||
if (open.value && item.key === active.value) {
|
||||
store.setSlot(item.key, slotEls[item.key] || null);
|
||||
} else if (store.slots[item.key] && store.slots[item.key] === slotEls[item.key]) {
|
||||
store.setSlot(item.key, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
watch(active, () => nextTick(claim));
|
||||
watch(
|
||||
() => store.entries.length,
|
||||
() => {
|
||||
if (!store.find(active.value)) active.value = store.entries[0]?.key || '';
|
||||
},
|
||||
);
|
||||
|
||||
const hostTree = ref<Array<Host.HostTree>>([]);
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const hostFilter = ref('');
|
||||
const loadHosts = async () => {
|
||||
if (isNodeAdmin.value) return;
|
||||
const res = await getHostTree({});
|
||||
hostTree.value = res.data;
|
||||
};
|
||||
watch(hostFilter, (v) => treeRef.value?.filter(v));
|
||||
const filterHost = (value: string, data: any) => !value || data.label.toLowerCase().includes(value.toLowerCase());
|
||||
|
||||
const connect = async (wsID: number, title: string) => {
|
||||
if (wsID === 0) {
|
||||
const res = await testLocalConn();
|
||||
if (!res.data) {
|
||||
MsgError(i18n.global.t('terminal.connLocalErr'));
|
||||
return;
|
||||
}
|
||||
active.value = await store.open({ title, wsID });
|
||||
return;
|
||||
}
|
||||
const res = await testByID(wsID);
|
||||
active.value = await store.open({
|
||||
title,
|
||||
wsID,
|
||||
error: res.data ? '' : 'Authentication failed. Please check the host information!',
|
||||
});
|
||||
};
|
||||
|
||||
const closeAll = async () => {
|
||||
if (store.entries.length > 0) {
|
||||
await ElMessageBox.confirm(
|
||||
i18n.global.t('terminal.closeAllConfirm'),
|
||||
i18n.global.t('terminal.closeAllSessions'),
|
||||
{
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
store.closeAll();
|
||||
}
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
// the terminal page claims the slots itself; give ours up when navigating there
|
||||
watch(onTerminalPage, (v) => {
|
||||
if (v) open.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.terminal-dock-handle {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 96px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: var(--el-bg-color);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.terminal-dock-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.terminal-dock-label {
|
||||
writing-mode: vertical-rl;
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.terminal-dock-tree {
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.terminal-dock-slot {
|
||||
height: 60vh;
|
||||
background-color: var(--panel-logs-bg-color);
|
||||
}
|
||||
.terminal-dock-empty {
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<!-- Lives in the layout so terminal sessions outlive the terminal route.
|
||||
Each Terminal is teleported into the page's slot while the page is mounted
|
||||
and parked off-screen (still connected, still receiving output) otherwise. -->
|
||||
<div class="terminal-host" aria-hidden="true">
|
||||
<template v-for="item in store.entries" :key="item.key + ':' + item.refresh">
|
||||
<Teleport :to="store.slots[item.key] || 'body'" :disabled="!store.slots[item.key]">
|
||||
<Terminal
|
||||
:ref="(el: any) => store.setInstance(item.key, el)"
|
||||
@session="(id: string) => store.setSessionId(item.key, id)"
|
||||
@expired="store.onExpired(item.key)"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import Terminal from '@/components/terminal/index.vue';
|
||||
import { TerminalSessionStore } from '@/store';
|
||||
|
||||
const store = TerminalSessionStore();
|
||||
|
||||
// Sessions the agent still holds (page refresh, closed browser tab) are
|
||||
// reattached right away, without waiting for the terminal page.
|
||||
onMounted(() => store.restore());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Off-screen but sized, so xterm can measure and keep rendering while parked. */
|
||||
.terminal-host {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 1000px;
|
||||
height: 600px;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const fitAddon = new FitAddon();
|
||||
const termReady = ref(false);
|
||||
@@ -37,6 +46,18 @@ const terminalSocket = ref<WebSocket>();
|
||||
const heartbeatTimer = ref<NodeJS.Timer>();
|
||||
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<typeof setTimeout> | 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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2087,6 +2087,13 @@ const message = {
|
||||
key: 'کلید خصوصی',
|
||||
keyPassword: 'رمز عبور کلید خصوصی',
|
||||
emptyTerminal: 'در حال حاضر هیچ ترمینالی متصل نیست.',
|
||||
sessionReconnecting: 'اتصال قطع شد، در حال اتصال مجدد...',
|
||||
sessionExpired: 'نشست دیگر در دسترس نیست، برای باز کردن نشست جدید Enter را بزنید یا روی اتصال مجدد کلیک کنید',
|
||||
sessionKicked: 'این نشست در پنجره دیگری باز شده است',
|
||||
sessionCount: '{0} نشست',
|
||||
minimize: 'کوچکسازی',
|
||||
closeAllSessions: 'بستن همه نشستها',
|
||||
closeAllConfirm: 'همه نشستهای ترمینال قطع میشوند و قابل بازیابی نیستند. ادامه میدهید؟',
|
||||
lineHeight: 'ارتفاع خط',
|
||||
letterSpacing: 'فاصله بین حروف',
|
||||
fontSize: 'اندازه قلم',
|
||||
|
||||
@@ -2098,6 +2098,14 @@ const message = {
|
||||
key: '秘密鍵',
|
||||
keyPassword: '秘密キーパスワード',
|
||||
emptyTerminal: '現在接続されている端子はありません。',
|
||||
sessionReconnecting: '接続が切断されました。再接続しています...',
|
||||
sessionExpired:
|
||||
'セッションは無効になりました。Enter キーまたは再接続をクリックして新しいセッションを開いてください',
|
||||
sessionKicked: 'このセッションは別のウィンドウで開かれました',
|
||||
sessionCount: '{0} セッション',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: 'すべてのセッションを閉じる',
|
||||
closeAllConfirm: 'すべてのターミナルセッションが切断され、復元できません。続行しますか?',
|
||||
lineHeight: '行の高さ',
|
||||
letterSpacing: '文字間隔',
|
||||
fontSize: 'フォントサイズ',
|
||||
|
||||
@@ -2067,6 +2067,13 @@ const message = {
|
||||
key: '개인 키',
|
||||
keyPassword: '개인 키 비밀번호',
|
||||
emptyTerminal: '현재 연결된 터미널이 없습니다.',
|
||||
sessionReconnecting: '연결이 끊어졌습니다. 다시 연결하는 중...',
|
||||
sessionExpired: '세션을 더 이상 사용할 수 없습니다. Enter 키를 누르거나 다시 연결을 클릭하여 새 세션을 여세요',
|
||||
sessionKicked: '이 세션은 다른 창에서 열렸습니다',
|
||||
sessionCount: '세션 {0}개',
|
||||
minimize: '최소화',
|
||||
closeAllSessions: '모든 세션 닫기',
|
||||
closeAllConfirm: '모든 터미널 세션이 끊기며 복구할 수 없습니다. 계속하시겠습니까?',
|
||||
lineHeight: '줄 높이',
|
||||
letterSpacing: '자간',
|
||||
fontSize: '글꼴 크기',
|
||||
|
||||
@@ -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: 'ຂະໜາດຕົວອັກສອນ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2122,6 +2122,13 @@ const message = {
|
||||
key: 'Приватный ключ',
|
||||
keyPassword: 'Пароль приватного ключа',
|
||||
emptyTerminal: 'В настоящее время нет подключенных терминалов.',
|
||||
sessionReconnecting: 'Соединение потеряно, переподключение...',
|
||||
sessionExpired: 'Сессия больше недоступна, нажмите Enter или «Переподключить», чтобы открыть новую',
|
||||
sessionKicked: 'Эта сессия была открыта в другом окне',
|
||||
sessionCount: 'сессий: {0}',
|
||||
minimize: 'Свернуть',
|
||||
closeAllSessions: 'Закрыть все сессии',
|
||||
closeAllConfirm: 'Все сессии терминала будут отключены без возможности восстановления. Продолжить?',
|
||||
lineHeight: 'Высота строки',
|
||||
letterSpacing: 'Межбуквенный интервал',
|
||||
fontSize: 'Размер шрифта',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1990,6 +1990,13 @@ const message = {
|
||||
key: '私鑰',
|
||||
keyPassword: '私鑰密碼',
|
||||
emptyTerminal: '暫無終端連接',
|
||||
sessionReconnecting: '連線已中斷,正在重新連線...',
|
||||
sessionExpired: '會話已失效,按 Enter 或點擊重新連線以新建會話',
|
||||
sessionKicked: '該會話已在其他視窗開啟',
|
||||
sessionCount: '{0} 會話',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: '關閉所有會話',
|
||||
closeAllConfirm: '將斷開全部終端會話且無法恢復,是否繼續?',
|
||||
lineHeight: '字體行高',
|
||||
letterSpacing: '字體間距',
|
||||
fontSize: '字體大小',
|
||||
|
||||
@@ -2020,6 +2020,13 @@ const message = {
|
||||
key: '私钥',
|
||||
keyPassword: '私钥密码',
|
||||
emptyTerminal: '暂无终端连接',
|
||||
sessionReconnecting: '连接已断开,正在重连...',
|
||||
sessionExpired: '会话已失效,按回车或点击重连以新建会话',
|
||||
sessionKicked: '该会话已在其他窗口打开',
|
||||
sessionCount: '{0} 会话',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: '关闭所有会话',
|
||||
closeAllConfirm: '将断开全部终端会话且无法恢复,是否继续?',
|
||||
lineHeight: '字体行高',
|
||||
letterSpacing: '字体间距',
|
||||
fontSize: '字体大小',
|
||||
|
||||
@@ -74,11 +74,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { TabsStore } from '@/store';
|
||||
import { TabsStore, TerminalSessionStore } from '@/store';
|
||||
import i18n from '@/lang';
|
||||
import { Close, DArrowLeft, DArrowRight, Lock, More, Unlock } from '@element-plus/icons-vue';
|
||||
|
||||
const tabsStore = TabsStore();
|
||||
const terminalSessions = TerminalSessionStore();
|
||||
|
||||
const props = defineProps({
|
||||
tabItem: {
|
||||
@@ -99,6 +100,10 @@ const menuName = computed(() => {
|
||||
if (props.tabItem.meta.detail) {
|
||||
title = title + '-' + i18n.global.t(props.tabItem.meta.detail);
|
||||
}
|
||||
// live sessions: off the page only pinned ones survive, under a locked (keep-alive) tab all do
|
||||
if (props.tabItem.path === '/terminal' && terminalSessions.entries.length > 0) {
|
||||
title = title + ' (' + i18n.global.t('terminal.sessionCount', [terminalSessions.entries.length]) + ')';
|
||||
}
|
||||
return title;
|
||||
});
|
||||
|
||||
|
||||
@@ -48,12 +48,16 @@
|
||||
<Footer class="app-footer" v-if="!isFullScreen" />
|
||||
</div>
|
||||
<TaskList ref="taskListRef" />
|
||||
<TerminalHost />
|
||||
<TerminalDock />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed, ref, watch, onBeforeUnmount } from 'vue';
|
||||
import { Sidebar, Footer, AppMain, MobileHeader, Tabs } from './components';
|
||||
import TerminalHost from '@/components/terminal/host.vue';
|
||||
import TerminalDock from '@/components/terminal/dock/index.vue';
|
||||
import useResize from './hooks/useResize';
|
||||
import { MenuStore, TabsStore } from '@/store';
|
||||
import { getSystemAvailable } from '@/api/modules/setting';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { hasRouteAccess } from '@/utils/rbac';
|
||||
import { loadProductProFromDB } from '@/utils/xpack';
|
||||
import i18n from '@/lang';
|
||||
import { MsgError } from '@/utils/message';
|
||||
import { TerminalSessionStore } from '@/store';
|
||||
|
||||
const axiosCanceler = new AxiosCanceler();
|
||||
|
||||
@@ -24,6 +25,7 @@ const clearLoginStatus = () => {
|
||||
globalStore.setLogStatus(false);
|
||||
globalStore.clearAuthInfo();
|
||||
clearLicenseStatus();
|
||||
TerminalSessionStore().closeAll();
|
||||
};
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import GlobalStore from './modules/global';
|
||||
import MenuStore from './modules/menu';
|
||||
import TabsStore from './modules/tabs';
|
||||
import TerminalStore from './modules/terminal';
|
||||
import TerminalSessionStore from './modules/terminal-session';
|
||||
import ProcessStore from './modules/process';
|
||||
|
||||
const pinia = createPinia();
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
|
||||
export { GlobalStore, MenuStore, TabsStore, TerminalStore, ProcessStore };
|
||||
export { GlobalStore, MenuStore, TabsStore, TerminalStore, TerminalSessionStore, ProcessStore };
|
||||
|
||||
export default pinia;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { ref, reactive, shallowReactive, markRaw, nextTick } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { searchTerminalSessions } from '@/api/modules/terminal';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import i18n from '@/lang';
|
||||
|
||||
// A web terminal session is bound to the browser tab, not to the route.
|
||||
// Entries live here for as long as the SPA does; the Terminal components that
|
||||
// own the websocket and xterm are rendered by components/terminal/host.vue and
|
||||
// teleported into whichever slot (terminal page, floating dock) claims them.
|
||||
// The agent keeps a session alive for a while after the websocket drops, so
|
||||
// restore() can rebuild the entries after a page refresh or a closed browser tab.
|
||||
export interface TerminalSessionEntry {
|
||||
key: string;
|
||||
title: string;
|
||||
wsID: number; // 0 = local shell
|
||||
endpoint: string;
|
||||
args: string;
|
||||
sessionId: string; // agent side id, known once the hello arrived
|
||||
status: 'online' | 'closed';
|
||||
latency: number;
|
||||
refresh: number; // bump to remount the Terminal component
|
||||
}
|
||||
|
||||
const localEndpoint = '/api/v2/hosts/terminal/local';
|
||||
const sshEndpoint = '/api/v2/hosts/terminal/ssh';
|
||||
|
||||
const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
|
||||
const entries = ref<TerminalSessionEntry[]>([]);
|
||||
// Terminal component instances and slot elements, keyed by entry key.
|
||||
const instances = reactive<Record<string, any>>({});
|
||||
const slots = shallowReactive<Record<string, HTMLElement | undefined>>({});
|
||||
|
||||
const find = (key: string) => entries.value.find((e) => e.key === key);
|
||||
|
||||
// The Terminal components render in the layout level host; wait for it to render ours.
|
||||
const instanceOf = async (key: string) => {
|
||||
for (let i = 0; i < 5 && !instances[key]; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
return instances[key];
|
||||
};
|
||||
|
||||
const add = (init: { title: string; wsID: number; args?: string; status?: 'online' | 'closed' }) => {
|
||||
const key = newUUID();
|
||||
const q = `title=${encodeURIComponent(init.title)}`;
|
||||
let title = init.title;
|
||||
let args = init.args || '';
|
||||
if (init.wsID === 0) {
|
||||
// A local shell is pinned to the node it was opened on. Without this the
|
||||
// websocket url would follow later node switches and a reconnect after a
|
||||
// blip would silently open a shell on a different node. ssh shells (wsID > 0)
|
||||
// always run on the master, see components/terminal/index.vue.
|
||||
const { currentNode } = useGlobalStore();
|
||||
const node = /operateNode=([^&]+)/.exec(args)?.[1] || encodeURIComponent(currentNode.value || 'local');
|
||||
if (!args.includes('operateNode=')) args = [args, `operateNode=${node}`].filter(Boolean).join('&');
|
||||
if (node !== 'local') title = `${title} (${decodeURIComponent(node)})`;
|
||||
}
|
||||
entries.value.push({
|
||||
key,
|
||||
title,
|
||||
wsID: init.wsID,
|
||||
endpoint: init.wsID === 0 ? localEndpoint : sshEndpoint,
|
||||
args: [init.wsID === 0 ? '' : `id=${init.wsID}`, args, q].filter(Boolean).join('&'),
|
||||
sessionId: '',
|
||||
status: init.status || 'online',
|
||||
latency: 0,
|
||||
refresh: 0,
|
||||
});
|
||||
return key;
|
||||
};
|
||||
|
||||
// open adds an entry and connects it. error is shown instead of connecting when set.
|
||||
const open = async (init: { title: string; wsID: number; initCmd?: string; error?: string }) => {
|
||||
const key = add({ ...init, status: init.error ? 'closed' : 'online' });
|
||||
const e = find(key)!;
|
||||
const inst = await instanceOf(key);
|
||||
inst?.acceptParams({
|
||||
endpoint: e.endpoint,
|
||||
args: e.args,
|
||||
initCmd: init.initCmd || '',
|
||||
error: init.error || '',
|
||||
});
|
||||
return key;
|
||||
};
|
||||
|
||||
// reconnect remounts the Terminal; an entry that still has an agent session reattaches to it.
|
||||
const reconnect = async (key: string, error = '', initCmd = '') => {
|
||||
const e = find(key);
|
||||
if (!e) return;
|
||||
e.refresh++;
|
||||
await nextTick();
|
||||
const inst = await instanceOf(key);
|
||||
inst?.acceptParams({ endpoint: e.endpoint, args: e.args, initCmd, sessionId: e.sessionId, error });
|
||||
};
|
||||
|
||||
// restore rebuilds entries from the agent's session list and reattaches them.
|
||||
const restore = async () => {
|
||||
const { currentNode } = useGlobalStore();
|
||||
const node = currentNode.value || 'local';
|
||||
const results = await Promise.allSettled([
|
||||
searchTerminalSessions(false),
|
||||
...(node === 'local' ? [] : [searchTerminalSessions(true)]),
|
||||
]);
|
||||
results.forEach((r, i) => {
|
||||
if (r.status !== 'fulfilled') return;
|
||||
const fromLocalNode = i === 1 || node === 'local';
|
||||
for (const s of r.value.data || []) {
|
||||
// attached elsewhere = another browser tab is using it; do not steal it
|
||||
if (s.attached || entries.value.some((e) => e.sessionId === s.id)) continue;
|
||||
if (s.hostId > 0 && !fromLocalNode) continue; // ssh sessions are served by the local node
|
||||
// a local shell found on the master while another node is selected must stay pinned to the master
|
||||
const key = add({
|
||||
title: s.title || i18n.global.t('terminal.localhost'),
|
||||
wsID: s.hostId,
|
||||
args: s.hostId === 0 && i === 1 ? 'operateNode=local' : '',
|
||||
});
|
||||
find(key)!.sessionId = s.id;
|
||||
}
|
||||
});
|
||||
for (const e of entries.value) {
|
||||
if (e.sessionId) await reconnect(e.key);
|
||||
}
|
||||
};
|
||||
|
||||
// remove drops entries; the host unmounts their Terminals, which closes the websockets with 1000.
|
||||
const removeWhere = (match: (e: TerminalSessionEntry) => boolean) => {
|
||||
for (const e of entries.value.filter(match)) {
|
||||
delete instances[e.key];
|
||||
delete slots[e.key];
|
||||
}
|
||||
entries.value = entries.value.filter((e) => !match(e));
|
||||
};
|
||||
const remove = (key: string) => removeWhere((e) => e.key === key);
|
||||
// closeAll runs on logout (or an expired login).
|
||||
const closeAll = () => removeWhere(() => true);
|
||||
|
||||
const setSessionId = (key: string, id: string) => {
|
||||
const e = find(key);
|
||||
if (!e) return;
|
||||
e.sessionId = id;
|
||||
e.status = 'online';
|
||||
};
|
||||
|
||||
// onExpired: the agent no longer has the session; the next reconnect opens a fresh one.
|
||||
const onExpired = (key: string) => {
|
||||
const e = find(key);
|
||||
if (!e) return;
|
||||
e.sessionId = '';
|
||||
e.status = 'closed';
|
||||
};
|
||||
|
||||
const setInstance = (key: string, inst: any) => {
|
||||
if (inst) {
|
||||
instances[key] = markRaw(inst);
|
||||
} else {
|
||||
delete instances[key];
|
||||
}
|
||||
};
|
||||
|
||||
const setSlot = (key: string, el: HTMLElement | null) => {
|
||||
slots[key] = el || undefined;
|
||||
};
|
||||
|
||||
// sync pulls status/latency from the live components.
|
||||
const sync = () => {
|
||||
for (const e of entries.value) {
|
||||
const inst = instances[e.key];
|
||||
if (!inst) continue;
|
||||
e.status = inst.isWsOpen() ? 'online' : 'closed';
|
||||
e.latency = inst.getLatency();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
entries,
|
||||
instances,
|
||||
slots,
|
||||
find,
|
||||
open,
|
||||
reconnect,
|
||||
restore,
|
||||
remove,
|
||||
closeAll,
|
||||
setSessionId,
|
||||
onExpired,
|
||||
setInstance,
|
||||
setSlot,
|
||||
sync,
|
||||
};
|
||||
});
|
||||
|
||||
export default TerminalSessionStore;
|
||||
@@ -37,7 +37,7 @@ import HostTab from '@/views/terminal/host/index.vue';
|
||||
import CommandTab from '@/views/terminal/command/index.vue';
|
||||
import TerminalTab from '@/views/terminal/terminal/index.vue';
|
||||
import SettingTab from '@/views/terminal/setting/index.vue';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { getTerminalInfo } from '@/api/modules/setting';
|
||||
import { TerminalStore } from '@/store';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
@@ -86,9 +86,6 @@ onMounted(() => {
|
||||
loadTerminalSetting();
|
||||
handleChange('terminal');
|
||||
});
|
||||
onUnmounted(() => {
|
||||
terminalTabRef.value?.cleanTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
@edit="handleTabsRemove"
|
||||
>
|
||||
<el-tab-pane
|
||||
:key="item.index"
|
||||
v-for="item in terminalTabs"
|
||||
:key="item.key"
|
||||
v-for="item in store.entries"
|
||||
:closable="true"
|
||||
:label="item.title"
|
||||
:name="item.index"
|
||||
:name="item.key"
|
||||
>
|
||||
<template #label>
|
||||
<span class="custom-tabs-label">
|
||||
@@ -43,14 +43,15 @@
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<Terminal
|
||||
<!-- The Terminal itself is rendered by components/terminal/host.vue and teleported here. -->
|
||||
<div
|
||||
class="terminal-slot"
|
||||
:ref="(el: any) => onSlot(item.key, el)"
|
||||
:style="{
|
||||
height: `calc(100vh - ${loadHeight()})`,
|
||||
'background-color': `var(--panel-logs-bg-color)`,
|
||||
}"
|
||||
:ref="'t-' + item.index"
|
||||
:key="item.Refresh"
|
||||
></Terminal>
|
||||
></div>
|
||||
|
||||
<div class="flex items-center gap-2 w-full py-2 flex-wrap">
|
||||
<AiSetting v-if="!isMobile" class="shrink-0" />
|
||||
@@ -207,7 +208,7 @@
|
||||
</el-popover>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<div v-if="terminalTabs.length === 0">
|
||||
<div v-if="store.entries.length === 0">
|
||||
<el-empty
|
||||
:style="{ height: `calc(100vh - ${loadEmptyHeight()})`, 'background-color': '#000' }"
|
||||
:description="$t('terminal.emptyTerminal')"
|
||||
@@ -234,8 +235,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, getCurrentInstance, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||
import Terminal from '@/components/terminal/index.vue';
|
||||
import { ref, watch, nextTick, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue';
|
||||
import HostDialog from '@/views/terminal/terminal/host-create.vue';
|
||||
import type Node from 'element-plus/es/components/tree/src/model/node';
|
||||
import { ElTree } from 'element-plus';
|
||||
@@ -249,11 +249,12 @@ import { getCommandTree } from '@/api/modules/command';
|
||||
import { getAgentSettingInfo } from '@/api/modules/setting';
|
||||
import AiSetting from '@/views/terminal/setting/ai/index.vue';
|
||||
import { MsgWarning } from '@/utils/message';
|
||||
import { TerminalSessionStore } from '@/store';
|
||||
|
||||
const { isFullScreen, isMobile, isNodeAdmin, openMenuTabs } = useGlobalStore();
|
||||
const store = TerminalSessionStore();
|
||||
|
||||
const dialogRef = ref();
|
||||
const ctx = getCurrentInstance() as any;
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (screenfull.isEnabled) {
|
||||
@@ -266,8 +267,6 @@ const loadTooltip = () => {
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
const terminalValue = ref();
|
||||
const terminalTabs = ref([]) as any;
|
||||
let tabIndex = 0;
|
||||
|
||||
const commandTree = ref();
|
||||
const quickCommandProps = {
|
||||
@@ -301,12 +300,17 @@ const acceptParams = async () => {
|
||||
} else {
|
||||
hostTree.value = [];
|
||||
}
|
||||
if (terminalTabs.value.length === 0) {
|
||||
if (store.entries.length === 0) {
|
||||
await openDefaultLocalConn();
|
||||
} else {
|
||||
// sessions kept alive while we were away: show them and re-fit to this container
|
||||
if (!store.find(terminalValue.value)) {
|
||||
terminalValue.value = store.entries[0].key;
|
||||
}
|
||||
await claim();
|
||||
store.sync();
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
syncTerminal();
|
||||
}, 1000 * 5);
|
||||
timer = setInterval(store.sync, 1000 * 5);
|
||||
if (!isMobile.value) {
|
||||
screenfull.on('change', () => {
|
||||
isFullScreen.value = screenfull.isFullscreen;
|
||||
@@ -326,15 +330,48 @@ const openDefaultLocalConn = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Leaving the page keeps every session connected in the host; only the poll stops.
|
||||
const cleanTimer = () => {
|
||||
clearInterval(Number(timer));
|
||||
timer = null;
|
||||
for (const terminal of terminalTabs.value) {
|
||||
if (ctx && ctx.refs[`t-${terminal.index}`][0]) {
|
||||
terminal.status = ctx.refs[`t-${terminal.index}`][0].onClose();
|
||||
};
|
||||
|
||||
// Slots are claimed explicitly, not from the ref callback: under a locked menu tab
|
||||
// (keep-alive) the page keeps rendering while detached, and the dock takes the
|
||||
// Terminals over meanwhile. Only a visible page owns its slots; release on leave
|
||||
// and take them back on return, the same way the dock does.
|
||||
const slotEls: Record<string, HTMLElement> = {};
|
||||
const onSlot = (key: string, el: HTMLElement | null) => {
|
||||
if (el) slotEls[key] = el;
|
||||
else delete slotEls[key];
|
||||
};
|
||||
let pageVisible = true;
|
||||
const claim = async () => {
|
||||
for (const item of store.entries) {
|
||||
if (pageVisible) {
|
||||
store.setSlot(item.key, slotEls[item.key] || null);
|
||||
} else if (store.slots[item.key] && store.slots[item.key] === slotEls[item.key]) {
|
||||
store.setSlot(item.key, null);
|
||||
}
|
||||
}
|
||||
if (!pageVisible) return;
|
||||
await nextTick();
|
||||
for (const item of store.entries) {
|
||||
store.instances[item.key]?.refit();
|
||||
}
|
||||
};
|
||||
watch(
|
||||
() => store.entries.length,
|
||||
() => nextTick(claim),
|
||||
);
|
||||
onActivated(() => {
|
||||
pageVisible = true;
|
||||
claim();
|
||||
});
|
||||
onDeactivated(() => {
|
||||
pageVisible = false;
|
||||
claim();
|
||||
});
|
||||
|
||||
const loadHeight = () => {
|
||||
return openMenuTabs.value ? '250px' : '210px';
|
||||
@@ -346,27 +383,27 @@ const loadFullScreenHeight = () => {
|
||||
return openMenuTabs.value ? '105px' : '60px';
|
||||
};
|
||||
|
||||
const handleTabsRemove = (targetName: string, action: 'remove' | 'add') => {
|
||||
const handleTabsRemove = async (targetName: string, action: 'remove' | 'add') => {
|
||||
if (action !== 'remove') {
|
||||
return;
|
||||
}
|
||||
if (ctx) {
|
||||
ctx.refs[`t-${targetName}`] && ctx.refs[`t-${targetName}`][0].onClose();
|
||||
if (!store.find(targetName)) {
|
||||
return;
|
||||
}
|
||||
const tabs = terminalTabs.value;
|
||||
const tabs = store.entries;
|
||||
let activeName = terminalValue.value;
|
||||
if (activeName === targetName) {
|
||||
tabs.forEach((tab: any, index: any) => {
|
||||
if (tab.index === targetName) {
|
||||
tabs.forEach((tab, index) => {
|
||||
if (tab.key === targetName) {
|
||||
const nextTab = tabs[index + 1] || tabs[index - 1];
|
||||
if (nextTab) {
|
||||
activeName = nextTab.index;
|
||||
activeName = nextTab.key;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
terminalValue.value = activeName;
|
||||
terminalTabs.value = tabs.filter((tab: any) => tab.index !== targetName);
|
||||
store.remove(targetName);
|
||||
};
|
||||
|
||||
const loadHostTree = async () => {
|
||||
@@ -394,16 +431,10 @@ const loadCommandTree = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const executeCommand = (command: string) => {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
if (isBatch.value) {
|
||||
for (const tab of terminalTabs.value) {
|
||||
ctx.refs[`t-${tab.index}`] && ctx.refs[`t-${tab.index}`][0].sendMsg(command + '\n');
|
||||
}
|
||||
} else {
|
||||
ctx.refs[`t-${terminalValue.value}`] && ctx.refs[`t-${terminalValue.value}`][0].sendMsg(command + '\n');
|
||||
const sendToTerminals = (command: string, all: boolean) => {
|
||||
const keys = all ? store.entries.map((e) => e.key) : [terminalValue.value];
|
||||
for (const key of keys) {
|
||||
store.instances[key]?.sendMsg(command);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -411,22 +442,15 @@ const handleQuickCommandChange = (val: Array<string>) => {
|
||||
if (!val?.length) {
|
||||
return;
|
||||
}
|
||||
executeCommand(val[val.length - 1]);
|
||||
sendToTerminals(val[val.length - 1] + '\n', isBatch.value);
|
||||
quickCmd.value = '';
|
||||
};
|
||||
|
||||
function batchInput() {
|
||||
if (batchVal.value === '' || !ctx) {
|
||||
if (batchVal.value === '') {
|
||||
return;
|
||||
}
|
||||
if (isBatch.value) {
|
||||
for (const tab of terminalTabs.value) {
|
||||
ctx.refs[`t-${tab.index}`] && ctx.refs[`t-${tab.index}`][0].sendMsg(batchVal.value + '\n');
|
||||
}
|
||||
batchVal.value = '';
|
||||
return;
|
||||
}
|
||||
ctx.refs[`t-${terminalValue.value}`] && ctx.refs[`t-${terminalValue.value}`][0].sendMsg(batchVal.value + '\n');
|
||||
sendToTerminals(batchVal.value + '\n', isBatch.value);
|
||||
batchVal.value = '';
|
||||
}
|
||||
|
||||
@@ -443,30 +467,22 @@ const onNewSsh = () => {
|
||||
}
|
||||
dialogRef.value!.acceptParams({ isLocal: false });
|
||||
};
|
||||
|
||||
const connectionError = 'Failed to set up the connection. Please check the host information';
|
||||
|
||||
const openTab = async (title: string, wsID: number, error: string) => {
|
||||
const cmd = initCmd.value;
|
||||
initCmd.value = '';
|
||||
terminalValue.value = await store.open({ title, wsID, initCmd: cmd, error });
|
||||
};
|
||||
|
||||
const onNewLocal = async () => {
|
||||
const res = await testLocalConn();
|
||||
if (!res.data) {
|
||||
dialogRef.value!.acceptParams({ isLocal: true });
|
||||
return;
|
||||
}
|
||||
terminalTabs.value.push({
|
||||
index: tabIndex,
|
||||
title: i18n.global.t('terminal.localhost'),
|
||||
wsID: 0,
|
||||
status: 'online',
|
||||
latency: 0,
|
||||
});
|
||||
terminalValue.value = tabIndex;
|
||||
nextTick(() => {
|
||||
ctx.refs[`t-${terminalValue.value}`] &&
|
||||
ctx.refs[`t-${terminalValue.value}`][0].acceptParams({
|
||||
endpoint: '/api/v2/hosts/terminal/local',
|
||||
initCmd: initCmd.value,
|
||||
error: '',
|
||||
});
|
||||
initCmd.value = '';
|
||||
});
|
||||
tabIndex++;
|
||||
await openTab(i18n.global.t('terminal.localhost'), 0, '');
|
||||
};
|
||||
|
||||
const onClickConn = (node: Node, data: Tree) => {
|
||||
@@ -477,37 +493,11 @@ const onClickConn = (node: Node, data: Tree) => {
|
||||
};
|
||||
|
||||
const onReconnect = async (item: any) => {
|
||||
if (ctx) {
|
||||
ctx.refs[`t-${item.index}`] && ctx.refs[`t-${item.index}`][0].onClose();
|
||||
}
|
||||
item.Refresh = !item.Refresh;
|
||||
if (item.wsID === 0) {
|
||||
const res = await testLocalConn();
|
||||
nextTick(() => {
|
||||
ctx.refs[`t-${item.index}`] &&
|
||||
ctx.refs[`t-${item.index}`][0].acceptParams({
|
||||
endpoint: '/api/v2/hosts/terminal/local',
|
||||
initCmd: initCmd.value,
|
||||
error: res.data ? '' : 'Failed to set up the connection. Please check the host information',
|
||||
});
|
||||
initCmd.value = '';
|
||||
});
|
||||
syncTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await testByID(item.wsID);
|
||||
nextTick(() => {
|
||||
ctx.refs[`t-${item.index}`] &&
|
||||
ctx.refs[`t-${item.index}`][0].acceptParams({
|
||||
endpoint: '/api/v2/hosts/terminal/ssh',
|
||||
args: `id=${item.wsID}`,
|
||||
initCmd: initCmd.value,
|
||||
error: res.data ? '' : 'Failed to set up the connection. Please check the host information',
|
||||
});
|
||||
initCmd.value = '';
|
||||
});
|
||||
syncTerminal();
|
||||
const res = item.wsID === 0 ? await testLocalConn() : await testByID(item.wsID);
|
||||
const cmd = initCmd.value;
|
||||
initCmd.value = '';
|
||||
await store.reconnect(item.key, res.data ? '' : connectionError, cmd);
|
||||
store.sync();
|
||||
};
|
||||
|
||||
const onConnTerminal = async (title: string, wsID: number) => {
|
||||
@@ -516,47 +506,23 @@ const onConnTerminal = async (title: string, wsID: number) => {
|
||||
return;
|
||||
}
|
||||
const res = await testByID(wsID);
|
||||
terminalTabs.value.push({
|
||||
index: tabIndex,
|
||||
title: title,
|
||||
wsID: wsID,
|
||||
status: res.data ? 'online' : 'closed',
|
||||
latency: 0,
|
||||
});
|
||||
terminalValue.value = tabIndex;
|
||||
nextTick(() => {
|
||||
ctx.refs[`t-${terminalValue.value}`] &&
|
||||
ctx.refs[`t-${terminalValue.value}`][0].acceptParams({
|
||||
endpoint: '/api/v2/hosts/terminal/ssh',
|
||||
args: `id=${wsID}`,
|
||||
initCmd: initCmd.value,
|
||||
error: res.data ? '' : 'Authentication failed. Please check the host information!',
|
||||
});
|
||||
initCmd.value = '';
|
||||
});
|
||||
tabIndex++;
|
||||
await openTab(title, wsID, res.data ? '' : 'Authentication failed. Please check the host information!');
|
||||
};
|
||||
|
||||
function syncTerminal() {
|
||||
for (const terminal of terminalTabs.value) {
|
||||
if (ctx && ctx.refs[`t-${terminal.index}`][0]) {
|
||||
terminal.status = ctx.refs[`t-${terminal.index}`][0].isWsOpen() ? 'online' : 'closed';
|
||||
terminal.latency = ctx.refs[`t-${terminal.index}`][0].getLatency();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changeFullScreen = () => {
|
||||
isFullScreen.value = screenfull.isFullscreen;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams,
|
||||
cleanTimer,
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('fullscreenchange', changeFullScreen);
|
||||
// parent refs are already null in the parent's onUnmounted, so leave-page cleanup lives here
|
||||
cleanTimer();
|
||||
pageVisible = false;
|
||||
claim();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
@@ -602,6 +568,10 @@ onMounted(() => {
|
||||
background-color: var(--el-tabs__item);
|
||||
}
|
||||
|
||||
.terminal-slot {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.host-tree {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
Reference in New Issue
Block a user