From 36f2bdaf81ae30b588ad507c6e6367d3e31f562f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Mon, 26 Jan 2026 20:09:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=88=E7=AB=AF=E6=94=AF=E6=8C=81pin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/shell/pty.go | 12 ++++++ pkg/ssh/turn.go | 11 ++++++ web/src/views/ssh/IndexView.vue | 66 ++++++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/pkg/shell/pty.go b/pkg/shell/pty.go index 2e7dafd7..9310327b 100644 --- a/pkg/shell/pty.go +++ b/pkg/shell/pty.go @@ -22,6 +22,11 @@ type MessageResize struct { Rows uint `json:"rows"` } +// MessagePing ping 消息 +type MessagePing struct { + Ping bool `json:"ping"` +} + // Turn PTY 终端 type Turn struct { ctx context.Context @@ -78,6 +83,7 @@ func (t *Turn) Close() { // Handle 从 WebSocket 读取输入写入 PTY func (t *Turn) Handle(ctx context.Context) error { var resize MessageResize + var ping MessagePing go func() { _ = t.Pipe(ctx) }() @@ -92,6 +98,12 @@ func (t *Turn) Handle(ctx context.Context) error { return fmt.Errorf("failed to read ws message: %w", err) } + // 判断是否是 ping 消息 + if err = json.Unmarshal(data, &ping); err == nil && ping.Ping { + _ = t.ws.Write(ctx, websocket.MessageText, []byte(`{"pong":true}`)) + continue + } + // 判断是否是 resize 消息 if err = json.Unmarshal(data, &resize); err == nil { if resize.Resize && resize.Columns > 0 && resize.Rows > 0 { diff --git a/pkg/ssh/turn.go b/pkg/ssh/turn.go index a10c5743..d8f4705c 100644 --- a/pkg/ssh/turn.go +++ b/pkg/ssh/turn.go @@ -17,6 +17,10 @@ type MessageResize struct { Rows int `json:"rows"` } +type MessagePing struct { + Ping bool `json:"ping"` +} + type Turn struct { ctx context.Context stdin io.WriteCloser @@ -72,6 +76,7 @@ func (t *Turn) Close() { func (t *Turn) Handle(ctx context.Context) error { var resize MessageResize + var ping MessagePing for { select { @@ -84,6 +89,12 @@ func (t *Turn) Handle(ctx context.Context) error { return fmt.Errorf("reading ws message err: %v", err) } + // 判断是否是 ping 消息 + if err = json.Unmarshal(data, &ping); err == nil && ping.Ping { + _ = t.ws.Write(ctx, websocket.MessageText, []byte(`{"pong":true}`)) + continue + } + // 判断是否是 resize 消息 if err = json.Unmarshal(data, &resize); err == nil { if resize.Resize && resize.Columns > 0 && resize.Rows > 0 { diff --git a/web/src/views/ssh/IndexView.vue b/web/src/views/ssh/IndexView.vue index 666dd0d9..ad0ff547 100644 --- a/web/src/views/ssh/IndexView.vue +++ b/web/src/views/ssh/IndexView.vue @@ -34,6 +34,9 @@ interface TerminalTab { ws: WebSocket | null element: HTMLElement | null connected: boolean + latency: number + pingTimer: ReturnType | null + lastPingTime: number } // 状态 @@ -159,7 +162,10 @@ const addTab = async (hostId: number) => { webglAddon: null, ws: null, element: null, - connected: false + connected: false, + latency: 0, + pingTimer: null, + lastPingTime: 0 } tabs.value.push(tab) activeTabId.value = tabId @@ -253,6 +259,18 @@ const initTerminal = async (tabId: string) => { tab.ws.onmessage = (ev) => { const data: ArrayBuffer | string = ev.data + // 检查是否是 pong 响应 + if (typeof data === 'string') { + try { + const json = JSON.parse(data) + if (json.pong) { + handlePong(tab) + return + } + } catch { + // 不是 JSON,正常处理 + } + } tab.terminal?.write(typeof data === 'string' ? data : new Uint8Array(data)) } @@ -288,6 +306,9 @@ const initTerminal = async (tabId: string) => { tab.terminal.focus() tab.connected = true + // 启动延迟检测 + startPingTimer(tab) + tab.ws.onclose = () => { tab.connected = false tab.terminal?.write('\r\n' + $gettext('Connection closed. Please refresh.')) @@ -308,6 +329,10 @@ const initTerminal = async (tabId: string) => { // 销毁标签 const disposeTab = (tab: TerminalTab) => { try { + if (tab.pingTimer) { + clearInterval(tab.pingTimer) + tab.pingTimer = null + } tab.ws?.close() tab.terminal?.dispose() tab.fitAddon = null @@ -402,6 +427,43 @@ const applyFontSettings = () => { }) } +// 启动延迟检测定时器 +const startPingTimer = (tab: TerminalTab) => { + // 立即执行一次 + sendPing(tab) + // 每3秒检测一次 + tab.pingTimer = setInterval(() => { + sendPing(tab) + }, 3000) +} + +// 发送 ping +const sendPing = (tab: TerminalTab) => { + if (tab.ws?.readyState === WebSocket.OPEN) { + tab.lastPingTime = performance.now() + tab.ws.send(JSON.stringify({ ping: true })) + } +} + +// 处理 pong 响应 +const handlePong = (tab: TerminalTab) => { + if (tab.lastPingTime > 0) { + tab.latency = Math.round(performance.now() - tab.lastPingTime) + tab.lastPingTime = 0 + } +} + +// 渲染标签标题 +const renderTabLabel = (tab: TerminalTab) => { + const latencyColor = tab.connected ? '#18a058' : '#d03050' + const icon = tab.connected ? '✓' : '✗' + return h('span', { class: 'tab-label' }, [ + h('span', { style: { color: latencyColor, marginRight: '4px' } }, `${tab.latency} ms`), + h('span', { style: { color: latencyColor, marginRight: '4px' } }, icon), + tab.name + ]) +} + // 全屏切换 const toggleFullscreen = async () => { const container = terminalContainer.value @@ -479,7 +541,7 @@ onUnmounted(() => { v-for="tab in tabs" :key="tab.id" :name="tab.id" - :tab="tab.name" + :tab="renderTabLabel(tab)" display-directive="show:lazy" >