From 61e7b64333b8a197f32bd2538f9aebe52411a443 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Sat, 10 May 2025 01:23:47 +0800 Subject: [PATCH] update --- .../src/services/ssh-suspend.service.ts | 182 ++++++----- packages/backend/src/websocket.ts | 6 +- packages/backend/src/websocket/connection.ts | 166 ++++++---- .../src/websocket/handlers/ssh.handler.ts | 15 + packages/backend/src/websocket/types.ts | 26 +- packages/backend/src/websocket/utils.ts | 78 ++++- .../src/composables/useSshTerminal.ts | 47 ++- .../src/composables/useWebSocketConnection.ts | 30 +- .../stores/session/actions/sessionActions.ts | 8 +- .../session/actions/sshSuspendActions.ts | 303 ++++++++++++------ packages/frontend/src/stores/session/types.ts | 2 + .../frontend/src/types/websocket.types.ts | 25 +- .../src/views/SuspendedSshSessionsView.vue | 41 ++- 13 files changed, 653 insertions(+), 276 deletions(-) diff --git a/packages/backend/src/services/ssh-suspend.service.ts b/packages/backend/src/services/ssh-suspend.service.ts index 86a092e..121cb4e 100644 --- a/packages/backend/src/services/ssh-suspend.service.ts +++ b/packages/backend/src/services/ssh-suspend.service.ts @@ -8,7 +8,8 @@ import { SuspendedSessionInfo, } from '../types/ssh-suspend.types'; import { temporaryLogStorageService, TemporaryLogStorageService } from './temporary-log-storage.service'; -import { clientStates } from '../websocket/state'; // +++ 导入 clientStates +++ +import { ClientState } from '../websocket/types'; +// clientStates 的直接访问已移除,因为takeOverMarkedSession现在从调用者接收所需信息 /** * SshSuspendService 负责管理所有用户的挂起 SSH 会话的生命周期。 @@ -37,48 +38,59 @@ export class SshSuspendService extends EventEmitter { } /** - * 启动指定 SSH 会话的挂起模式。 - * @param userId 用户ID。 - * @param originalSessionId 原始会话ID。 - * @param sshClient SSH 客户端实例。 - * @param channel SSH 通道实例。 - * @param connectionName 连接名称。 - * @param connectionId 连接ID。 - * @param customSuspendName 可选的自定义挂起名称。 - * @returns Promise 返回生成的 suspendSessionId。 + * 当一个被标记为待挂起的会话的 WebSocket 连接断开时,由此方法接管 SSH 资源。 + * @param details 包含接管所需的所有会话详细信息。 + * @returns Promise 返回新生成的 suspendSessionId,如果无法接管则返回 null。 */ - async startSuspend( - userId: number, // userId: string -> number - originalSessionId: string, - sshClient: Client, - channel: ClientChannel, // 更新为 ClientChannel - connectionName: string, - connectionId: string, - customSuspendName?: string, - ): Promise { + async takeOverMarkedSession(details: { + userId: number; + originalSessionId: string; + sshClient: Client; + channel: ClientChannel; + connectionName: string; + connectionId: string; + logIdentifier: string; + customSuspendName?: string; + }): Promise { + const { + userId, + originalSessionId, + sshClient, + channel, + connectionName, + connectionId, + logIdentifier, + customSuspendName, + } = details; + + // 检查 SSH client 和 channel 是否仍然可用 + // ClientChannel 有 readable 和 writable, Client 本身没有直接的此类属性 + // 如果 channel 不可读写,通常意味着底层连接有问题。 + if (!channel || !channel.readable || !channel.writable) { + console.warn(`[用户: ${userId}] 尝试接管会话 ${originalSessionId} 时,SSH channel 已不可用 (readable: ${channel?.readable}, writable: ${channel?.writable})。将标记为已断开。`); + // 确保如果 SSH 连接已经关闭,日志文件仍然保留,但不创建挂起条目。 + // SshSuspendService 不会管理这个“已经断开”的会话,但日志保留供用户清理。 + try { channel?.end(); } catch (e) { /* ignore */ } + try { sshClient?.end(); } catch (e) { /* ignore */ } + return null; // 无法接管 + } + const suspendSessionId = uuidv4(); const userSessions = this.getUserSessions(userId); - // 在接管 channel 和 sshClient 前,移除它们上面可能存在的旧监听器 - // 这确保了 SshSuspendService 独占事件处理,避免旧的处理器(如 ssh.handler.ts 中的)继续发送数据或处理关闭事件 channel.removeAllListeners('data'); channel.removeAllListeners('close'); channel.removeAllListeners('error'); - channel.removeAllListeners('end'); // ClientChannel 也有 'end' 事件 - channel.removeAllListeners('exit'); // ClientChannel 也有 'exit' 事件 + channel.removeAllListeners('end'); + channel.removeAllListeners('exit'); - // 对于 sshClient,移除监听器需要谨慎,特别是如果 sshClient 实例可能被多个 Shell共享(尽管在此应用中通常不这么做) - // 假设这里的 sshClient 的生命周期与此 channel 紧密相关,或者是此 channel 的唯一父级。 sshClient.removeAllListeners('error'); sshClient.removeAllListeners('end'); - // sshClient.removeAllListeners('close'); // sshClient 本身没有 'close' 事件,通常是 'end' 或连接错误 - - const tempLogPath = `./data/temp_suspended_ssh_logs/${suspendSessionId}.log`; // 路径相对于项目根目录 const sessionDetails: SuspendSessionDetails = { sshClient, channel, - tempLogPath, + tempLogPath: logIdentifier, // 使用传入的日志标识符 (基于 originalSessionId) connectionName, connectionId, suspendStartTime: new Date().toISOString(), @@ -89,80 +101,66 @@ export class SshSuspendService extends EventEmitter { }; userSessions.set(suspendSessionId, sessionDetails); + console.log(`[用户: ${userId}] SSH会话 ${originalSessionId} 已被 SshSuspendService 接管 (新挂起ID: ${suspendSessionId})。日志文件标识: ${logIdentifier}`); - // +++ 更新 ClientState 标记 +++ - const originalClientState = clientStates.get(originalSessionId); - if (originalClientState) { - originalClientState.isSuspendedByService = true; - console.log(`[用户: ${userId}] ClientState for session ${originalSessionId} marked as suspended by service.`); - } else { - console.warn(`[用户: ${userId}] Could not find ClientState for original session ID ${originalSessionId} to mark as suspended.`); - } - // +++ 结束更新 ClientState 标记 +++ - - console.log(`[用户: ${userId}] SSH会话 ${originalSessionId} (连接: ${connectionName}) 已启动挂起,ID: ${suspendSessionId}`); - - // 确保日志目录存在 await this.logStorageService.ensureLogDirectoryExists(); - - // 开始监听通道数据并写入日志 + channel.on('data', (data: Buffer) => { if (userSessions.get(suspendSessionId)?.backendSshStatus === 'hanging') { - this.logStorageService.writeToLog(suspendSessionId, data.toString('utf-8')).catch(err => { - console.error(`[用户: ${userId}, 会话: ${suspendSessionId}] 写入挂起日志失败:`, err); + this.logStorageService.writeToLog(logIdentifier, data.toString('utf-8')).catch(err => { + console.error(`[用户: ${userId}, 挂起ID: ${suspendSessionId}, 日志: ${logIdentifier}] 写入挂起日志失败:`, err); }); } }); - const handleUnexpectedClose = () => { + const handleSessionTermination = (reasonSuffix: string) => { const currentSession = userSessions.get(suspendSessionId); if (currentSession && currentSession.backendSshStatus === 'hanging') { - const reason = 'SSH connection closed or errored.'; - console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH 连接意外断开。原因: ${reason}`); + const reason = `SSH connection ${reasonSuffix}.`; + console.warn(`[用户: ${userId}, 挂起ID: ${suspendSessionId}, 日志: ${logIdentifier}] SSH 连接在挂起期间终止。原因: ${reason}`); currentSession.backendSshStatus = 'disconnected_by_backend'; currentSession.disconnectionTimestamp = new Date().toISOString(); - this.removeChannelListeners(channel, sshClient); // 使用辅助方法移除 + this.removeChannelListeners(channel, sshClient); - // 发出事件通知 WebSocket 层 this.emit('sessionAutoTerminated', { - userId: currentSession.userId, // 使用存储在 sessionDetails 中的 userId + userId: currentSession.userId, suspendSessionId, reason }); } }; - channel.on('close', handleUnexpectedClose); + channel.on('close', () => handleSessionTermination('closed')); channel.on('error', (err: Error) => { - console.error(`[用户: ${userId}, 会话: ${suspendSessionId}] 通道错误:`, err); - handleUnexpectedClose(); + console.error(`[用户: ${userId}, 挂起ID: ${suspendSessionId}, 日志: ${logIdentifier}] 挂起会话的通道错误:`, err); + handleSessionTermination('errored'); }); + channel.on('end', () => handleSessionTermination('ended')); + channel.on('exit', (code: number | null, signalName: string | null) => handleSessionTermination(`exited with code ${code}, signal ${signalName}`)); + sshClient.on('error', (err: Error) => { - console.error(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH客户端错误:`, err); - handleUnexpectedClose(); + console.error(`[用户: ${userId}, 挂起ID: ${suspendSessionId}, 日志: ${logIdentifier}] 挂起会话的SSH客户端错误:`, err); + handleSessionTermination('client errored'); }); - sshClient.on('end', () => { // 'end' 通常是正常关闭,但也需要处理 - console.log(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH客户端连接结束。`); - handleUnexpectedClose(); // 如果是意外的,则标记为 disconnected + sshClient.on('end', () => { + console.log(`[用户: ${userId}, 挂起ID: ${suspendSessionId}, 日志: ${logIdentifier}] 挂起会话的SSH客户端连接结束。`); + handleSessionTermination('client ended'); }); - return suspendSessionId; } - /** - * 辅助方法:移除会话相关的事件监听器。 - */ private removeChannelListeners(channel: Channel, sshClient: Client): void { channel.removeAllListeners('data'); channel.removeAllListeners('close'); channel.removeAllListeners('error'); + channel.removeAllListeners('end'); + channel.removeAllListeners('exit'); sshClient.removeAllListeners('error'); sshClient.removeAllListeners('end'); } - /** * 列出指定用户的所有挂起会话(包括活跃和已断开的)。 * 目前主要从内存中获取信息。 @@ -196,26 +194,51 @@ export class SshSuspendService extends EventEmitter { * @returns Promise<{ sshClient: Client; channel: ClientChannel; logData: string; connectionName: string; originalConnectionId: string; } | null> 恢复成功则返回客户端、通道、日志数据、连接名和原始连接ID,否则返回null。 */ async resumeSession(userId: number, suspendSessionId: string): Promise<{ sshClient: Client; channel: ClientChannel; logData: string; connectionName: string; originalConnectionId: string; } | null> { + // console.log(`[SshSuspendService][用户: ${userId}] resumeSession 调用,suspendSessionId: ${suspendSessionId}`); const userSessions = this.getUserSessions(userId); const session = userSessions.get(suspendSessionId); - if (!session || session.backendSshStatus !== 'hanging') { - console.warn(`[用户: ${userId}] 尝试恢复的会话 ${suspendSessionId} 不存在或状态不正确 (${session?.backendSshStatus})。`); + if (!session) { + // console.warn(`[SshSuspendService][用户: ${userId}] resumeSession: 未找到挂起的会话 ${suspendSessionId}。`); + return null; + } + // console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 找到会话 ${suspendSessionId},状态: ${session.backendSshStatus}`); + + if (session.backendSshStatus !== 'hanging') { + // console.warn(`[SshSuspendService][用户: ${userId}] resumeSession: 会话 ${suspendSessionId} 状态不为 'hanging' (当前: ${session.backendSshStatus}),无法恢复。`); return null; } // 停止监听旧通道事件 this.removeChannelListeners(session.channel, session.sshClient); + // console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 已移除会话 ${suspendSessionId} 的旧监听器。`); - const logData = await this.logStorageService.readLog(suspendSessionId); + let logData = ''; + try { + // 使用 session.tempLogPath (即 logIdentifier, 基于 originalSessionId) 来读取日志 + logData = await this.logStorageService.readLog(session.tempLogPath); + console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 已读取挂起会话 ${suspendSessionId} (日志: ${session.tempLogPath}) 的数据,长度: ${logData.length}`); + } catch (error) { + // console.error(`[SshSuspendService][用户: ${userId}] resumeSession: 读取挂起会话 ${suspendSessionId} (日志: ${session.tempLogPath}) 失败:`, error); + // 根据策略,读取日志失败可能也应该导致恢复失败 + return null; + } // 在从 userSessions 删除会话之前,保存需要返回的会话详细信息 const { sshClient, channel, connectionName, connectionId: originalConnectionId } = session; userSessions.delete(suspendSessionId); - await this.logStorageService.deleteLog(suspendSessionId); + // console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 已从内存中删除挂起会话 ${suspendSessionId} 的记录。`); + try { + // 删除以 session.tempLogPath (logIdentifier) 命名的日志文件 + await this.logStorageService.deleteLog(session.tempLogPath); + // console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 已删除挂起会话 ${suspendSessionId} 的日志文件 (路径: ${session.tempLogPath})。`); + } catch (error) { + // console.warn(`[SshSuspendService][用户: ${userId}] resumeSession: 删除挂起会话 ${suspendSessionId} 的日志文件 (路径: ${session.tempLogPath}) 失败:`, error); + // 日志删除失败不应阻止恢复流程继续 + } - console.log(`[用户: ${userId}] 挂起会话 ${suspendSessionId} 已成功恢复。`); + // console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 挂起会话 ${suspendSessionId} 准备返回恢复数据。`); return { sshClient, channel, @@ -239,9 +262,10 @@ export class SshSuspendService extends EventEmitter { console.warn(`[用户: ${userId}] 尝试终止的会话 ${suspendSessionId} 不存在或不是活跃状态 (${session?.backendSshStatus})。`); // 如果会话已断开,但记录还在,也应该能被“终止”(即移除) if(session && session.backendSshStatus === 'disconnected_by_backend'){ + const logPathToDelete = session.tempLogPath; // 获取正确的日志路径 userSessions.delete(suspendSessionId); - await this.logStorageService.deleteLog(suspendSessionId); - console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} 已通过终止操作移除。`); + await this.logStorageService.deleteLog(logPathToDelete); + console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} (日志: ${logPathToDelete}) 已通过终止操作移除。`); return true; } return false; @@ -260,10 +284,11 @@ export class SshSuspendService extends EventEmitter { console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] 关闭sshClient时出错:`, e); } + const logPathToFinallyDelete = session.tempLogPath; // 获取正确的日志路径 userSessions.delete(suspendSessionId); - await this.logStorageService.deleteLog(suspendSessionId); + await this.logStorageService.deleteLog(logPathToFinallyDelete); - console.log(`[用户: ${userId}] 活跃的挂起会话 ${suspendSessionId} 已成功终止并移除。`); + console.log(`[用户: ${userId}] 活跃的挂起会话 ${suspendSessionId} (日志: ${logPathToFinallyDelete}) 已成功终止并移除。`); return true; } @@ -289,8 +314,13 @@ export class SshSuspendService extends EventEmitter { // 总是尝试删除日志文件,因为它可能对应一个已不在内存中的断开会话 try { - await this.logStorageService.deleteLog(suspendSessionId); - console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} 的日志已删除 (内存中状态: ${session ? session.backendSshStatus : '不在内存'})。`); + // suspendSessionId 在这里是用户从UI上选择的,可能在内存中,也可能不在 (只剩日志文件) + // 如果在内存中,session.tempLogPath 是正确的日志标识符 + // 如果不在内存中,suspendSessionId 本身可能就是日志文件名 (如果之前设计是这样的话,但现在统一用 originalSessionId 作为日志名基础) + // 假设 remove 请求中的 suspendSessionId 就是我们存储的那个挂起ID + const logPathToRemove = session ? session.tempLogPath : suspendSessionId; // 如果 session 不在内存,尝试直接用 suspendSessionId 作为日志文件名部分 + await this.logStorageService.deleteLog(logPathToRemove); + console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} 的日志 (标识: ${logPathToRemove}) 已删除 (内存中状态: ${session ? session.backendSshStatus : '不在内存'})。`); return true; } catch (error) { console.error(`[用户: ${userId}] 删除会话 ${suspendSessionId} 的日志文件失败:`, error); diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 68fb8d5..85764a9 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -6,6 +6,7 @@ import { initializeUpgradeHandler } from './websocket/upgrade'; import { initializeConnectionHandler } from './websocket/connection'; import { clientStates } from './websocket/state'; import { sshSuspendService } from './services/ssh-suspend.service'; // 导入实例 +import { SftpService } from './services/sftp.service'; // +++ 导入 SftpService +++ // TemporaryLogStorageService 是 SshSuspendService 的依赖,SshSuspendService 内部会处理它的实例化或导入, // websocket.ts 层面不需要直接使用 temporaryLogStorageService。 // 如果 SshSuspendService 的构造函数需要一个 TemporaryLogStorageService 实例, @@ -43,8 +44,11 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re // 2. Initialize Upgrade Handler (handles authentication and protocol upgrade) initializeUpgradeHandler(server, wss, sessionParser); + // +++ 创建 SftpService 实例 +++ + const sftpService = new SftpService(clientStates); + // 3. Initialize Connection Handler (handles 'connection' event and message routing) - initializeConnectionHandler(wss, sshSuspendService); // 传递 sshSuspendService 实例 + initializeConnectionHandler(wss, sshSuspendService, sftpService); // +++ 传递 sftpService 实例 +++ // --- WebSocket 服务器关闭处理 --- wss.on('close', () => { diff --git a/packages/backend/src/websocket/connection.ts b/packages/backend/src/websocket/connection.ts index c674c16..15342b9 100644 --- a/packages/backend/src/websocket/connection.ts +++ b/packages/backend/src/websocket/connection.ts @@ -15,12 +15,16 @@ import { SshSuspendTerminatedResponse, SshSuspendEntryRemovedResponse, SshSuspendNameEditedResponse, - SshSuspendAutoTerminatedNotification, // 尽管此消息由服务发起,但类型定义在此处有用 - ClientState // 导入 ClientState 以便访问 sshClient 等信息 + SshSuspendAutoTerminatedNotification, + SshMarkForSuspendRequest, // +++ 新增导入 + SshMarkedForSuspendAck, // +++ 新增导入 + ClientState } from './types'; import { SshSuspendService } from '../services/ssh-suspend.service'; +import { SftpService } from '../services/sftp.service'; import { cleanupClientConnection } from './utils'; -import { clientStates } from './state'; // Import clientStates for session management +import { clientStates } from './state'; +import { temporaryLogStorageService } from '../services/temporary-log-storage.service'; // +++ 新增导入 // Handlers import { handleRdpProxyConnection } from './handlers/rdp.handler'; @@ -41,7 +45,7 @@ import { handleSftpUploadCancel } from './handlers/sftp.handler'; -export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendService: SshSuspendService): void { +export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendService: SshSuspendService, sftpService: SftpService): void { // +++ Add sftpService parameter +++ wss.on('connection', (ws: AuthenticatedWebSocket, request: Request) => { ws.isAlive = true; const isRdpProxy = (request as any).isRdpProxy; @@ -126,48 +130,9 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ break; // --- SSH Suspend Cases --- - case 'SSH_SUSPEND_START': { - const { sessionId: originalFrontendSessionId } = payload as SshSuspendStartRequest['payload']; - console.log(`[WebSocket Handler] Received SSH_SUSPEND_START. UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, TargetOriginalFrontendSessionID: ${originalFrontendSessionId}`); - console.log(`[SSH_SUSPEND_START] (Debug) 当前 clientStates 中的 keys: ${JSON.stringify(Array.from(clientStates.keys()))}`); - // console.log(`[SSH_SUSPEND_START] 当前 WebSocket (ws.sessionId): ${ws.sessionId}`); // 重复,已包含在上一条日志 + // 旧的 SSH_SUSPEND_START 逻辑已被新的 SSH_MARK_FOR_SUSPEND 和 SshSuspendService.takeOverMarkedSession 取代 + // case 'SSH_SUSPEND_START': { ... } // Removed - if (!ws.userId) { - console.error(`[SSH_SUSPEND_START] 用户 ID 未定义。`); - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_STARTED_RESP', payload: { frontendSessionId: originalFrontendSessionId, suspendSessionId: '', success: false, error: '用户认证失败' } })); - break; - } - const activeSessionState = clientStates.get(originalFrontendSessionId); - if (!activeSessionState || !activeSessionState.sshClient || !activeSessionState.sshShellStream) { - console.error(`[SSH_SUSPEND_START] 找不到活动的SSH会话或其组件: ${originalFrontendSessionId}`); - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_STARTED_RESP', payload: { frontendSessionId: originalFrontendSessionId, suspendSessionId: '', success: false, error: '未找到活动的SSH会话' } })); - break; - } - try { - const suspendSessionId = await sshSuspendService.startSuspend( - ws.userId, - originalFrontendSessionId, - activeSessionState.sshClient, - activeSessionState.sshShellStream, - activeSessionState.connectionName || '未知连接', - String(activeSessionState.dbConnectionId), // 确保是 string - // customSuspendName 初始时可以为空或基于 connectionName - ); - const response: SshSuspendStartedResponse = { - type: 'SSH_SUSPEND_STARTED', - payload: { frontendSessionId: originalFrontendSessionId, suspendSessionId, success: true } - }; - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response)); - // 设计文档提到:“原有的直接将 SSH 输出发送到前端 WebSocket 的逻辑需要暂停或修改” - // 这部分可能需要修改 ssh.handler.ts,或者 SshSuspendService 内部通过移除监听器等方式实现。 - // SshSuspendService.startSuspend 内部应该已经处理了数据流重定向到日志。 - // clientStates.delete(originalFrontendSessionId); // 原会话不再由 websocket 直接管理,转由 SshSuspendService 管理 - } catch (error: any) { - console.error(`[SSH_SUSPEND_START] 启动挂起失败:`, error); - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_STARTED_RESP', payload: { frontendSessionId: originalFrontendSessionId, suspendSessionId: '', success: false, error: error.message || '启动挂起失败' } })); - } - break; - } case 'SSH_SUSPEND_LIST_REQUEST': { if (!ws.userId) { console.error(`[SSH_SUSPEND_LIST_REQUEST] 用户 ID 未定义。`); @@ -188,18 +153,22 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ break; } case 'SSH_SUSPEND_RESUME_REQUEST': { - const { suspendSessionId, newFrontendSessionId } = payload as SshSuspendResumeRequest['payload']; - console.log(`[WebSocket Handler] Received SSH_SUSPEND_RESUME_REQUEST. UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, SuspendSessionID: ${suspendSessionId}, NewFrontendSessionID: ${newFrontendSessionId}`); + const resumePayload = payload as SshSuspendResumeRequest['payload']; + const { suspendSessionId, newFrontendSessionId } = resumePayload; + // console.log(`[WebSocket Handler][${type}] 接到请求。UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, Payload: ${JSON.stringify(resumePayload)}`); + if (!ws.userId) { - console.error(`[SSH_SUSPEND_RESUME_REQUEST] 用户 ID 未定义。Payload: ${JSON.stringify(payload)}`); + console.error(`[WebSocket Handler][${type}] 用户 ID 未定义。`); if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_RESUMED_NOTIF', payload: { suspendSessionId, newFrontendSessionId, success: false, error: '用户认证失败' } })); break; } try { + // console.log(`[WebSocket Handler][${type}] 调用 sshSuspendService.resumeSession (userId: ${ws.userId}, suspendSessionId: ${suspendSessionId})`); const result = await sshSuspendService.resumeSession(ws.userId, suspendSessionId); + // console.log(`[WebSocket Handler][${type}] sshSuspendService.resumeSession 返回: ${result ? `包含 sshClient: ${!!result.sshClient}, channel: ${!!result.channel}, logData长度: ${result.logData?.length}` : 'null'}`); + if (result) { - // 将恢复的 sshClient 和 channel 重新关联到新的前端会话 ID - // 这部分逻辑需要与 handleSshConnect 类似,创建一个新的 ClientState + // console.log(`[WebSocket Handler][${type}] 成功恢复会话。准备设置新的 ClientState (ID: ${newFrontendSessionId})。`); const newSessionState: ClientState = { ws, // 当前的 WebSocket 连接 sshClient: result.sshClient, @@ -211,48 +180,74 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ }; clientStates.set(newFrontendSessionId, newSessionState); ws.sessionId = newFrontendSessionId; // 将当前 ws 与新会话关联 + // console.log(`[WebSocket Handler][${type}] 新 ClientState (ID: ${newFrontendSessionId}) 已设置并关联到当前 WebSocket。`); + + // +++ 为恢复的会话初始化 SFTP +++ + // console.log(`[WebSocket Handler][${type}] 尝试为恢复的会话 ${newFrontendSessionId} 初始化 SFTP。`); + sftpService.initializeSftpSession(newFrontendSessionId) + .then(() => { + // console.log(`[WebSocket Handler][${type}] SFTP 初始化调用完成 (可能异步) for ${newFrontendSessionId}。`); + // sftp_ready 消息会由 sftpService 内部发送 + }) + .catch(sftpInitErr => { + console.error(`[WebSocket Handler][${type}] 为恢复的会话 ${newFrontendSessionId} 初始化 SFTP 失败:`, sftpInitErr); + // 即使 SFTP 初始化失败,SSH 会话仍然恢复 + }); + // +++ 结束 SFTP 初始化 +++ // 重新设置事件监听器,将数据流导向新的前端会话 result.channel.removeAllListeners('data'); // 清除 SshSuspendService 可能设置的监听器 result.channel.on('data', (data: Buffer) => { if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'ssh:output', payload: { sessionId: newFrontendSessionId, data: data.toString('utf-8') } })); + // console.debug(`[WebSocket Handler][${type}] 发送 ssh:output for ${newFrontendSessionId}`); + // 保持与 ssh.handler.ts 中 ssh:output 格式一致 + ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); } }); result.channel.on('close', () => { + console.log(`[WebSocket Handler][${type}] 恢复的会话 ${newFrontendSessionId} 的 channel 已关闭。`); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: { sessionId: newFrontendSessionId } })); } cleanupClientConnection(newFrontendSessionId); }); result.sshClient.on('error', (err: Error) => { - console.error(`恢复后的 SSH 客户端错误 (会话: ${newFrontendSessionId}):`, err); + console.error(`[WebSocket Handler][${type}] 恢复后的 SSH 客户端错误 (会话: ${newFrontendSessionId}):`, err); if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ssh:error', payload: { sessionId: newFrontendSessionId, error: err.message } })); cleanupClientConnection(newFrontendSessionId); }); + // console.log(`[WebSocket Handler][${type}] 已为恢复的会话 ${newFrontendSessionId} 设置事件监听器。`); // 发送缓存日志块 - // 设计文档建议 SSH_OUTPUT_CACHED_CHUNK - // 这个服务返回的是一个完整的 logData 字符串,我们需要分块吗? - // 假设暂时不分块,或者由前端处理。如果需要分块,逻辑会更复杂。 - // 这里简单处理,一次性发送。如果日志过大,这可能不是最佳实践。 + console.log('[SSH Suspend Backend] Log data to send to frontend:', result.logData); const logChunkResponse: SshOutputCachedChunk = { type: 'SSH_OUTPUT_CACHED_CHUNK', payload: { frontendSessionId: newFrontendSessionId, data: result.logData, isLastChunk: true } }; - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(logChunkResponse)); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(logChunkResponse)); + // console.log(`[WebSocket Handler][${type}] 已发送 SSH_OUTPUT_CACHED_CHUNK 给 ${newFrontendSessionId} (数据长度: ${result.logData.length})。`); + } else { + // console.warn(`[WebSocket Handler][${type}] WebSocket 在发送 SSH_OUTPUT_CACHED_CHUNK 前已关闭 (会话 ${newFrontendSessionId})。`); + } const response: SshSuspendResumedNotification = { type: 'SSH_SUSPEND_RESUMED', payload: { suspendSessionId, newFrontendSessionId, success: true } }; - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response)); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(response)); + // console.log(`[WebSocket Handler][${type}] 已发送 SSH_SUSPEND_RESUMED_NOTIF 给 ${newFrontendSessionId}。`); + } else { + // console.warn(`[WebSocket Handler][${type}] WebSocket 在发送 SSH_SUSPEND_RESUMED_NOTIF 前已关闭 (会话 ${newFrontendSessionId})。`); + } } else { - throw new Error('无法恢复会话,或会话不存在/状态不正确。'); + // console.warn(`[WebSocket Handler][${type}] sshSuspendService.resumeSession 返回 null,无法恢复会话 ${suspendSessionId}。`); + throw new Error('服务未能恢复会话,或会话不存在/状态不正确。'); } } catch (error: any) { - console.error(`[SSH_SUSPEND_RESUME_REQUEST] 恢复会话 ${suspendSessionId} 失败:`, error); + // console.error(`[WebSocket Handler][${type}] 处理恢复会话 ${suspendSessionId} 时发生错误:`, error); if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_RESUMED_NOTIF', payload: { suspendSessionId, newFrontendSessionId, success: false, error: error.message || '恢复会话失败' } })); } break; @@ -319,6 +314,57 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ } break; } + case 'SSH_MARK_FOR_SUSPEND': { + const markPayload = payload as SshMarkForSuspendRequest['payload']; + const sessionToMarkId = markPayload.sessionId; + console.log(`[WebSocket Handler] Received SSH_MARK_FOR_SUSPEND. UserID: ${ws.userId}, TargetSessionID: ${sessionToMarkId}`); + + if (!ws.userId) { + console.error(`[SSH_MARK_FOR_SUSPEND] 用户 ID 未定义。`); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_MARKED_FOR_SUSPEND_ACK', payload: { sessionId: sessionToMarkId, success: false, error: '用户认证失败' } as SshMarkedForSuspendAck['payload'] })); + break; + } + + const activeSessionState = clientStates.get(sessionToMarkId); + if (!activeSessionState || !activeSessionState.sshClient || !activeSessionState.sshShellStream) { + console.error(`[SSH_MARK_FOR_SUSPEND] 找不到活动的SSH会话或其组件: ${sessionToMarkId}`); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_MARKED_FOR_SUSPEND_ACK', payload: { sessionId: sessionToMarkId, success: false, error: '未找到要标记的活动SSH会话' } as SshMarkedForSuspendAck['payload'] })); + break; + } + + if (activeSessionState.isMarkedForSuspend) { + console.warn(`[SSH_MARK_FOR_SUSPEND] 会话 ${sessionToMarkId} 已被标记。`); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_MARKED_FOR_SUSPEND_ACK', payload: { sessionId: sessionToMarkId, success: true, error: '会话已被标记' } as SshMarkedForSuspendAck['payload'] })); + break; + } + + try { + // 使用活动会话ID作为日志文件名的一部分 + const logPathSuffix = sessionToMarkId; // 使用原始 sessionId 作为日志文件名 + activeSessionState.isMarkedForSuspend = true; + activeSessionState.suspendLogPath = logPathSuffix; // 存储日志标识符 (服务内部会拼接完整路径) + + // 确保日志目录存在 (服务内部通常会做,但这里也可以调用一次) + await temporaryLogStorageService.ensureLogDirectoryExists(); + // 可以在这里预先写入一个标记,表明日志开始记录 + await temporaryLogStorageService.writeToLog(logPathSuffix, `--- Log recording started for session ${sessionToMarkId} at ${new Date().toISOString()} ---\n`); + + console.log(`[SSH_MARK_FOR_SUSPEND] 会话 ${sessionToMarkId} 已成功标记待挂起。日志将记录到与 ${logPathSuffix} 关联的文件。`); + const response: SshMarkedForSuspendAck = { + type: 'SSH_MARKED_FOR_SUSPEND_ACK', + payload: { sessionId: sessionToMarkId, success: true } + }; + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response)); + } catch (error: any) { + console.error(`[SSH_MARK_FOR_SUSPEND] 标记会话 ${sessionToMarkId} 失败:`, error); + if (activeSessionState) { // 如果状态存在,尝试回滚标记 + activeSessionState.isMarkedForSuspend = false; + activeSessionState.suspendLogPath = undefined; + } + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_MARKED_FOR_SUSPEND_ACK', payload: { sessionId: sessionToMarkId, success: false, error: error.message || '标记会话失败' } as SshMarkedForSuspendAck['payload'] })); + } + break; + } default: console.warn(`WebSocket:收到来自 ${ws.username} (会话: ${sessionId}) 的未知消息类型: ${type}`); if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'error', payload: `不支持的消息类型: ${type}` })); diff --git a/packages/backend/src/websocket/handlers/ssh.handler.ts b/packages/backend/src/websocket/handlers/ssh.handler.ts index dbaea14..044e924 100644 --- a/packages/backend/src/websocket/handlers/ssh.handler.ts +++ b/packages/backend/src/websocket/handlers/ssh.handler.ts @@ -4,6 +4,7 @@ import { AuthenticatedWebSocket, ClientState } from '../types'; import { clientStates, sftpService, statusMonitorService, auditLogService, notificationService } from '../state'; import * as SshService from '../../services/ssh.service'; import { cleanupClientConnection } from '../utils'; +import { temporaryLogStorageService } from '../../services/temporary-log-storage.service'; // +++ 新增导入 import { startDockerStatusPolling } from './docker.handler'; import WebSocket from 'ws'; @@ -102,12 +103,26 @@ export async function handleSshConnect( if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); } + // 如果会话被标记为待挂起,则将输出写入日志 + const currentState = clientStates.get(newSessionId); // 获取最新的状态 + if (currentState?.isMarkedForSuspend && currentState.suspendLogPath) { + temporaryLogStorageService.writeToLog(currentState.suspendLogPath, data.toString('utf-8')).catch(err => { + console.error(`[SSH Handler] 写入标记会话 ${newSessionId} 的日志失败 (路径: ${currentState.suspendLogPath}):`, err); + }); + } }); stream.stderr.on('data', (data: Buffer) => { console.error(`SSH Stderr (会话: ${newSessionId}): ${data.toString('utf8').substring(0, 100)}...`); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); } + // 同样,如果会话被标记为待挂起,则将 stderr 输出写入日志 + const currentState = clientStates.get(newSessionId); + if (currentState?.isMarkedForSuspend && currentState.suspendLogPath) { + temporaryLogStorageService.writeToLog(currentState.suspendLogPath, `[STDERR] ${data.toString('utf-8')}`).catch(err => { + console.error(`[SSH Handler] 写入标记会话 ${newSessionId} 的 STDERR 日志失败 (路径: ${currentState.suspendLogPath}):`, err); + }); + } }); stream.on('close', () => { console.log(`SSH: 会话 ${newSessionId} 的 Shell 通道已关闭。`); diff --git a/packages/backend/src/websocket/types.ts b/packages/backend/src/websocket/types.ts index 59b4d75..6e4cf1a 100644 --- a/packages/backend/src/websocket/types.ts +++ b/packages/backend/src/websocket/types.ts @@ -22,6 +22,9 @@ export interface ClientState { // 导出以便 Service 可以导入 ipAddress?: string; // 添加 IP 地址字段 isShellReady?: boolean; // 新增:标记 Shell 是否已准备好处理输入和调整大小 isSuspendedByService?: boolean; // 新增:标记此会话是否已被 SshSuspendService 接管 + isMarkedForSuspend?: boolean; // 新增:标记此会话是否已被用户请求挂起(等待断开连接) + suspendLogPath?: string; // 新增:如果标记挂起,则存储日志路径 + suspendLogWritableStream?: NodeJS.WritableStream; // 新增:用于写入挂起日志的流 } export interface PortInfo { @@ -65,6 +68,7 @@ export interface SshSuspendStartRequest { type: "SSH_SUSPEND_START"; payload: { sessionId: string; // The ID of the active SSH session to be suspended + initialBuffer?: string; // Optional: content of the terminal buffer at the time of suspend }; } @@ -102,6 +106,13 @@ export interface SshSuspendEditNameRequest { }; } +export interface SshMarkForSuspendRequest { + type: "SSH_MARK_FOR_SUSPEND"; + payload: { + sessionId: string; // The ID of the active SSH session to be marked + }; +} + // Server -> Client export interface SshSuspendStartedResponse { type: "SSH_SUSPEND_STARTED"; @@ -177,6 +188,15 @@ export interface SshSuspendNameEditedResponse { }; } +export interface SshMarkedForSuspendAck { + type: "SSH_MARKED_FOR_SUSPEND_ACK"; + payload: { + sessionId: string; // The ID of the session that was marked + success: boolean; + error?: string; + }; +} + export interface SshSuspendAutoTerminatedNotification { type: "SSH_SUSPEND_AUTO_TERMINATED"; payload: { @@ -192,7 +212,8 @@ export type SshSuspendClientToServerMessages = | SshSuspendResumeRequest | SshSuspendTerminateRequest | SshSuspendRemoveEntryRequest - | SshSuspendEditNameRequest; + | SshSuspendEditNameRequest + | SshMarkForSuspendRequest; // Added new request type // Union type for all server-to-client messages for SSH Suspend export type SshSuspendServerToClientMessages = @@ -203,7 +224,8 @@ export type SshSuspendServerToClientMessages = | SshSuspendTerminatedResponse | SshSuspendEntryRemovedResponse | SshSuspendNameEditedResponse - | SshSuspendAutoTerminatedNotification; + | SshSuspendAutoTerminatedNotification + | SshMarkedForSuspendAck; // Added new response type // It might be useful to have a general type for incoming messages if not already present // For example, if you have a main message handler: diff --git a/packages/backend/src/websocket/utils.ts b/packages/backend/src/websocket/utils.ts index 9ff315f..c8a8701 100644 --- a/packages/backend/src/websocket/utils.ts +++ b/packages/backend/src/websocket/utils.ts @@ -1,7 +1,8 @@ import { PortInfo, ClientState } from './types'; -import { SftpService } from '../services/sftp.service'; // 将被 state.ts 中的实例替换,但类型导入保留 -import { StatusMonitorService } from '../services/status-monitor.service'; // 将被 state.ts 中的实例替换,但类型导入保留 +import { SftpService } from '../services/sftp.service'; +import { StatusMonitorService } from '../services/status-monitor.service'; import { clientStates, sftpService, statusMonitorService } from './state'; +import { sshSuspendService } from '../services/ssh-suspend.service'; // +++ 新增导入 +++ // --- 新增:解析 Ports 字符串的辅助函数 --- export function parsePortsString(portsString: string | undefined | null): PortInfo[] { @@ -66,29 +67,74 @@ export function parsePortsString(portsString: string | undefined | null): PortIn * 清理指定会话 ID 关联的所有资源 * @param sessionId - 会话 ID */ -export const cleanupClientConnection = (sessionId: string | undefined) => { +export const cleanupClientConnection = async (sessionId: string | undefined) => { // Made async if (!sessionId) return; const state = clientStates.get(sessionId); if (state) { console.log(`WebSocket: 清理会话 ${sessionId} (用户: ${state.ws.username}, DB 连接 ID: ${state.dbConnectionId})...`); - // 1. 停止状态轮询 - statusMonitorService.stopStatusPolling(sessionId); + // 1. 停止状态轮询 (如果存在) + if (statusMonitorService) statusMonitorService.stopStatusPolling(sessionId); - // 2. 清理 SFTP 会话 - sftpService.cleanupSftpSession(sessionId); + // 2. 清理 SFTP 会话 (如果存在) + if (sftpService) sftpService.cleanupSftpSession(sessionId); - // 3. 清理 SSH 连接 - // +++ 仅当会话未被 SshSuspendService 接管时才关闭 SSH 连接 +++ - if (!state.isSuspendedByService) { - state.sshShellStream?.end(); // 结束 shell 流 - state.sshClient?.end(); // 结束 SSH 客户端 - console.log(`WebSocket: 会话 ${sessionId} 的 SSH 连接已关闭 (未被挂起服务接管)。`); - } else { - console.log(`WebSocket: 会话 ${sessionId} 的 SSH 连接由挂起服务管理,跳过关闭。`); + // 3. 处理 SSH 连接 (核心修改点) + if (state.isMarkedForSuspend && state.sshClient && state.sshShellStream && state.suspendLogPath && state.ws.userId !== undefined) { + console.log(`WebSocket: 会话 ${sessionId} 已被标记为待挂起,尝试移交给 SshSuspendService...`); + try { + const takeoverDetails = { + userId: state.ws.userId, + originalSessionId: sessionId, // sessionId 是原始活动会话的ID + sshClient: state.sshClient, + channel: state.sshShellStream, + connectionName: state.connectionName || '未知连接', + connectionId: String(state.dbConnectionId), + logIdentifier: state.suspendLogPath, // 这是基于 originalSessionId 的日志标识 + customSuspendName: undefined, // 如果需要,可以从 state 或其他地方获取 + }; + + // 从 state 中“分离”SSH资源,防止后续意外关闭 + const sshClientToPass = state.sshClient; + const channelToPass = state.sshShellStream; + state.sshClient = undefined as any; // 清除引用 + state.sshShellStream = undefined; // 清除引用 + state.isSuspendedByService = true; // 标记为已被服务接管(即使是尝试接管) + + const newSuspendId = await sshSuspendService.takeOverMarkedSession({ + ...takeoverDetails, + sshClient: sshClientToPass, // 传递分离出来的实例 + channel: channelToPass, // 传递分离出来的实例 + }); + + if (newSuspendId) { + console.log(`WebSocket: 会话 ${sessionId} 已成功移交给 SshSuspendService,新的挂起ID: ${newSuspendId}。SSH 连接将由服务管理。`); + // SSH 资源已移交,不需要在这里关闭它们 + } else { + console.warn(`WebSocket: 会话 ${sessionId} 移交给 SshSuspendService 失败 (takeOverMarkedSession 返回 null)。可能 SSH 连接在标记后已断开。将执行常规清理。`); + // 移交失败,执行常规关闭 + channelToPass?.end(); + sshClientToPass?.end(); + state.isSuspendedByService = false; // 重置标记,因为接管失败 + } + } catch (error) { + console.error(`WebSocket: 会话 ${sessionId} 移交给 SshSuspendService 时发生错误:`, error); + // 发生错误,也执行常规关闭以防资源泄露 + if (state.sshClient) state.sshClient.end(); // 如果引用还在,尝试关闭 + if (state.sshShellStream) state.sshShellStream.end(); // 如果引用还在,尝试关闭 + state.isSuspendedByService = false; // 重置标记 + } + } else if (!state.isSuspendedByService && state.sshClient) { + // 未标记挂起,也未被服务接管,执行常规关闭 + state.sshShellStream?.end(); + state.sshClient?.end(); + console.log(`WebSocket: 会话 ${sessionId} 的 SSH 连接已关闭 (未标记挂起,未被服务接管)。`); + } else if (state.isSuspendedByService) { + // 已被服务接管(例如通过旧的 startSuspend 流程,或成功移交后),不在此处关闭 + console.log(`WebSocket: 会话 ${sessionId} 的 SSH 连接已由挂起服务管理,跳过关闭。`); } - // +++ 结束条件关闭 +++ + // 4. 清理 Docker 状态轮询定时器 if (state.dockerStatusIntervalId) { diff --git a/packages/frontend/src/composables/useSshTerminal.ts b/packages/frontend/src/composables/useSshTerminal.ts index c2843eb..4e976e5 100644 --- a/packages/frontend/src/composables/useSshTerminal.ts +++ b/packages/frontend/src/composables/useSshTerminal.ts @@ -1,5 +1,6 @@ import { ref, readonly, type Ref, ComputedRef } from 'vue'; -import { useI18n } from 'vue-i18n'; // +++ Add import for useI18n +++ +import { useI18n } from 'vue-i18n'; +import { sessions as globalSessionsRef } from '../stores/session/state'; // +++ 导入全局 sessions state +++ // import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入 import type { Terminal } from 'xterm'; import type { SearchAddon, ISearchOptions } from '@xterm/addon-search'; // *** 移除 ISearchResult 导入 *** @@ -74,18 +75,40 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD // } // --- 添加日志:检查缓冲区处理 --- - console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 准备处理缓冲区,缓冲区长度: ${terminalOutputBuffer.value.length}`); - if (terminalOutputBuffer.value.length > 0) { - console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 缓冲区内容 (前100字符):`, terminalOutputBuffer.value.map(d => d.substring(0, 100)).join(' | ')); - } + // console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 准备处理缓冲区,缓冲区长度: ${terminalOutputBuffer.value.length}`); + // if (terminalOutputBuffer.value.length > 0) { + // console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 内部缓冲区内容 (前100字符):`, terminalOutputBuffer.value.map(d => d.substring(0, 100)).join(' | ')); + // } // --------------------------------- - // 将缓冲区的输出写入终端 - terminalOutputBuffer.value.forEach(data => { - console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 正在写入缓冲数据 (前100字符):`, data.substring(0, 100)); - term.write(data); - }); - console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 缓冲区处理完成。`); - terminalOutputBuffer.value = []; // 清空缓冲区 + + // 1. 处理 SessionState.pendingOutput (来自 SSH_OUTPUT_CACHED_CHUNK 的早期数据) + const currentSessionState = globalSessionsRef.value.get(sessionId); + if (currentSessionState && currentSessionState.pendingOutput && currentSessionState.pendingOutput.length > 0) { + // console.log(`[会话 ${sessionId}][SSH终端模块] 发现 SessionState.pendingOutput,长度: ${currentSessionState.pendingOutput.length}。正在写入...`); + currentSessionState.pendingOutput.forEach(data => { + term.write(data); + }); + currentSessionState.pendingOutput = []; // 清空 + // console.log(`[会话 ${sessionId}][SSH终端模块] SessionState.pendingOutput 处理完毕。`); + // 如果之前因为 pendingOutput 而将 isResuming 保持为 true,现在可以考虑更新 + if (currentSessionState.isResuming) { + // 检查 isLastChunk 是否已收到 (这部分逻辑在 handleSshOutputCachedChunk 中,这里仅作标记清除) + // 假设所有缓存块都已处理完毕 + // console.log(`[会话 ${sessionId}][SSH终端模块] 所有 pendingOutput 已写入,清除 isResuming 标记。`); + currentSessionState.isResuming = false; + } + } + + // 2. 将此管理器内部缓冲的输出 (terminalOutputBuffer, 来自 ssh:output) 写入终端 + if (terminalOutputBuffer.value.length > 0) { + terminalOutputBuffer.value.forEach(data => { + // console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 正在写入内部缓冲数据 (前100字符):`, data.substring(0, 100)); + term.write(data); + }); + // console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 内部缓冲区处理完成。`); + terminalOutputBuffer.value = []; // 清空内部缓冲区 + } + // 可以在这里自动聚焦或执行其他初始化操作 // term.focus(); // 也许在 ssh:connected 时聚焦更好 }; diff --git a/packages/frontend/src/composables/useWebSocketConnection.ts b/packages/frontend/src/composables/useWebSocketConnection.ts index bcf3d6a..2286c52 100644 --- a/packages/frontend/src/composables/useWebSocketConnection.ts +++ b/packages/frontend/src/composables/useWebSocketConnection.ts @@ -13,12 +13,20 @@ export type WsConnectionStatus = WsConnectionStatusType; * @param {string} sessionId - 此 WebSocket 连接关联的会话 ID (用于日志记录)。 * @param {string} dbConnectionId - 此 WebSocket 连接关联的数据库连接 ID (用于后端识别)。 * @param {Function} t - i18n 翻译函数,从父组件传入 + * @param {object} [options] - 可选参数对象 + * @param {boolean} [options.isResumeFlow=false] - 指示此连接是否用于 SSH 恢复流程 * @returns 一个包含状态和方法的 WebSocket 连接管理器对象。 */ -export function createWebSocketConnectionManager(sessionId: string, dbConnectionId: string, t: ReturnType['t']) { // +++ Update type of t +++ +export function createWebSocketConnectionManager( + sessionId: string, + dbConnectionId: string, + t: ReturnType['t'], + options?: { isResumeFlow?: boolean } +) { // --- Instance State --- // 每个实例拥有独立的 WebSocket 对象、状态和消息处理器 const ws = shallowRef(null); // WebSocket 实例 + const isResumeFlow = options?.isResumeFlow ?? false; // 获取恢复流程标志 const connectionStatus = ref('disconnected'); // 连接状态 (使用导出的类型) const statusMessage = ref(''); // 状态描述文本 const isSftpReady = ref(false); // SFTP 是否就绪 @@ -167,15 +175,24 @@ export function createWebSocketConnectionManager(sessionId: string, dbConnection reconnectAttempts = 0; // 连接成功,重置尝试次数 statusMessage.value = getStatusText('wsConnected'); // 状态保持 'connecting' 直到收到 ssh:connected - // 发送后端所需的初始连接消息,包含数据库连接 ID - sendMessage({ type: 'ssh:connect', payload: { connectionId: instanceDbConnectionId } }); + if (!isResumeFlow) { + // 对于普通连接,发送 ssh:connect 并等待 ssh:connected 来更新状态 + sendMessage({ type: 'ssh:connect', payload: { connectionId: instanceDbConnectionId } }); + } else { + // 对于恢复流程,WebSocket 打开即表示连接基础已建立 + // 后续的 SSH_SUSPEND_RESUME_REQUEST 会完成会话的恢复 + connectionStatus.value = 'connected'; + console.log(`[WebSocket ${instanceSessionId}] 恢复流程:WebSocket 打开,状态直接设为 connected。`); + } dispatchMessage('internal:opened', {}, { type: 'internal:opened' }); // 触发内部打开事件 }; ws.value.onmessage = (event: MessageEvent) => { try { - const message: WebSocketMessage = JSON.parse(event.data); - // console.debug(`[WebSocket ${instanceSessionId}] 收到:`, message.type); + const rawData = event.data; + // console.log(`[WebSocket ${instanceSessionId}] onmessage: 收到原始数据 (类型: ${typeof rawData}, 长度: ${rawData.toString().length}) 前100字符:`, rawData.toString().substring(0, 100)); + const message: WebSocketMessage = JSON.parse(rawData.toString()); + // console.log(`[WebSocket ${instanceSessionId}] onmessage: 解析后消息类型: ${message.type}, 会话ID (消息内): ${message.sessionId || 'N/A'}, Payload keys: ${message.payload ? Object.keys(message.payload).join(', ') : 'N/A'}`); // --- 更新此实例的连接状态 --- if (message.type === 'ssh:connected') { @@ -293,8 +310,9 @@ export function createWebSocketConnectionManager(sessionId: string, dbConnection if (ws.value && ws.value.readyState === WebSocket.OPEN) { try { const messageString = JSON.stringify(message); - // console.debug(`[WebSocket ${instanceSessionId}] 发送:`, message.type); + // console.log(`[WebSocket ${instanceSessionId}] sendMessage: 准备发送消息。类型: ${message.type}, 会话ID (消息内): ${message.sessionId || 'N/A'}, Payload keys: ${message.payload ? Object.keys(message.payload).join(', ') : 'N/A'}`); ws.value.send(messageString); + // console.log(`[WebSocket ${instanceSessionId}] sendMessage: 消息已发送。类型: ${message.type}`); } catch (e) { console.error(`[WebSocket ${instanceSessionId}] 序列化或发送消息失败:`, e, message); } diff --git a/packages/frontend/src/stores/session/actions/sessionActions.ts b/packages/frontend/src/stores/session/actions/sessionActions.ts index fe1fa35..a79fae9 100644 --- a/packages/frontend/src/stores/session/actions/sessionActions.ts +++ b/packages/frontend/src/stores/session/actions/sessionActions.ts @@ -43,7 +43,13 @@ export const openNewSession = ( const dbConnId = String(connInfo.id); // 1. 创建管理器实例 - const wsManager = createWebSocketConnectionManager(newSessionId, dbConnId, t); + const isResume = !!existingSessionId; // 如果提供了 existingSessionId,则为恢复流程 + const wsManager = createWebSocketConnectionManager( + newSessionId, + dbConnId, + t, + { isResumeFlow: isResume } // 传递 isResumeFlow 选项 + ); const sshTerminalDeps: SshTerminalDependencies = { sendMessage: wsManager.sendMessage, onMessage: wsManager.onMessage, diff --git a/packages/frontend/src/stores/session/actions/sshSuspendActions.ts b/packages/frontend/src/stores/session/actions/sshSuspendActions.ts index 0847b46..6a432b8 100644 --- a/packages/frontend/src/stores/session/actions/sshSuspendActions.ts +++ b/packages/frontend/src/stores/session/actions/sshSuspendActions.ts @@ -2,16 +2,15 @@ import { v4 as uuidv4 } from 'uuid'; import { sessions, suspendedSshSessions, isLoadingSuspendedSessions, activeSessionId } from '../state'; import type { - MessagePayload, // 新增导入 - SshSuspendStartReqMessage, - // SshSuspendListReqMessage, // 不再需要,因为 fetch 将通过 HTTP + MessagePayload, + SshMarkForSuspendReqMessage, // +++ 修改:导入新的请求类型 +++ SshSuspendResumeReqMessage, SshSuspendTerminateReqMessage, SshSuspendRemoveEntryReqMessage, SshSuspendEditNameReqMessage, // S2C Payloads - SshSuspendStartedRespPayload, - SshSuspendListResponsePayload, // 仍然需要处理来自 WS 的列表更新推送(如果后端支持) + SshMarkedForSuspendAckPayload, // +++ 新增:导入新的响应类型 +++ + SshSuspendListResponsePayload, SshSuspendResumedNotifPayload, SshOutputCachedChunkPayload, SshSuspendTerminatedRespPayload, @@ -20,7 +19,7 @@ import type { SshSuspendAutoTerminatedNotifPayload, } from '../../../types/websocket.types'; // 路径: packages/frontend/src/types/websocket.types.ts import type { WsManagerInstance, SessionState } from '../types'; // 路径: packages/frontend/src/stores/session/types.ts -import { closeSession as closeSessionAction, activateSession as activateSessionAction, openNewSession } from './sessionActions'; // 使用 openNewSession +import { closeSession as closeSessionAction, activateSession as activateSessionAction, openNewSession, closeSession } from './sessionActions'; // 使用 openNewSession 和 closeSession import { useConnectionsStore } from '../../connections.store'; // 用于获取连接信息 import { useUiNotificationsStore } from '../../uiNotifications.store'; // 用于显示通知 import type { SuspendedSshSession } from '../../../types/ssh-suspend.types'; // 路径: packages/frontend/src/types/ssh-suspend.types.ts @@ -34,33 +33,33 @@ const t: ComposerTranslation = i18n.global.t; // 从全局 i18n 实例获取 t // 优先使用当前激活的会话,或者任意一个已连接的 SSH 会话 // 注意:此函数主要用于那些仍然需要 WebSocket 的操作 (如 resume, terminate) const getActiveWsManager = (): WsManagerInstance | null => { - console.log(`[getActiveWsManager] 尝试获取可用 WebSocket。当前 sessions 数量: ${sessions.value.size}`); - sessions.value.forEach((session, sessionId) => { - console.log(`[getActiveWsManager] - 会话 ID: ${sessionId}, WS Manager 存在: ${!!session.wsManager}, WS 已连接: ${session.wsManager?.isConnected?.value}`); - }); + // console.log(`[getActiveWsManager] 尝试获取可用 WebSocket。当前 sessions 数量: ${sessions.value.size}`); + // sessions.value.forEach((session, sessionId) => { + // console.log(`[getActiveWsManager] - 会话 ID: ${sessionId}, WS Manager 存在: ${!!session.wsManager}, WS 已连接: ${session.wsManager?.isConnected?.value}`); + // }); const firstSessionKey = sessions.value.size > 0 ? sessions.value.keys().next().value : null; - console.log(`[getActiveWsManager] 尝试使用第一个会话 Key (如果存在): ${firstSessionKey}`); + // console.log(`[getActiveWsManager] 尝试使用第一个会话 Key (如果存在): ${firstSessionKey}`); if (firstSessionKey) { const session = sessions.value.get(firstSessionKey); - console.log(`[getActiveWsManager] 第一个会话 (ID: ${firstSessionKey}): WS Manager 存在: ${!!session?.wsManager}, WS 已连接: ${session?.wsManager?.isConnected?.value}`); + // console.log(`[getActiveWsManager] 第一个会话 (ID: ${firstSessionKey}): WS Manager 存在: ${!!session?.wsManager}, WS 已连接: ${session?.wsManager?.isConnected?.value}`); if (session && session.wsManager && session.wsManager.isConnected.value) { - console.log(`[getActiveWsManager] 使用第一个会话 (ID: ${firstSessionKey}) 的 WebSocket。`); + // console.log(`[getActiveWsManager] 使用第一个会话 (ID: ${firstSessionKey}) 的 WebSocket。`); return session.wsManager; } } - console.log('[getActiveWsManager] 第一个会话的 WebSocket 不可用或不存在,开始遍历所有会话...'); + // console.log('[getActiveWsManager] 第一个会话的 WebSocket 不可用或不存在,开始遍历所有会话...'); for (const [sessionId, session] of sessions.value) { - console.log(`[getActiveWsManager] 遍历中 - 检查会话 ID: ${sessionId}, WS Manager 存在: ${!!session.wsManager}, WS 已连接: ${session.wsManager?.isConnected?.value}`); + // console.log(`[getActiveWsManager] 遍历中 - 检查会话 ID: ${sessionId}, WS Manager 存在: ${!!session.wsManager}, WS 已连接: ${session.wsManager?.isConnected?.value}`); if (session.wsManager && session.wsManager.isConnected.value) { - console.log(`[getActiveWsManager] 遍历成功,使用会话 (ID: ${sessionId}) 的 WebSocket。`); + // console.log(`[getActiveWsManager] 遍历成功,使用会话 (ID: ${sessionId}) 的 WebSocket。`); return session.wsManager; } } - console.warn('[getActiveWsManager] 遍历结束,仍未找到可用的 WebSocket 连接来发送 SSH 挂起相关请求。'); + // console.warn('[getActiveWsManager] 遍历结束,仍未找到可用的 WebSocket 连接来发送 SSH 挂起相关请求。'); return null; }; @@ -73,18 +72,31 @@ export const requestStartSshSuspend = (sessionId: string): void => { const session = sessions.value.get(sessionId); if (session && session.wsManager) { if (!session.wsManager.isConnected.value) { - console.warn(`[${t('term.sshSuspend')}] WebSocket 未连接,无法启动挂起模式 (会话 ID: ${sessionId})。`); - // 可选:通知用户 + console.warn(`[${t('term.sshSuspend')}] WebSocket 未连接,无法请求标记挂起 (会话 ID: ${sessionId})。`); + useUiNotificationsStore().addNotification({ type: 'error', message: t('sshSuspend.notifications.wsNotConnectedError') }); return; } - const message: SshSuspendStartReqMessage = { - type: 'SSH_SUSPEND_START', + + // 不再需要获取 initialBuffer + + const message: SshMarkForSuspendReqMessage = { // +++ 修改:使用新的消息类型 +++ + type: 'SSH_MARK_FOR_SUSPEND', payload: { sessionId }, }; session.wsManager.sendMessage(message); - console.log(`[${t('term.sshSuspend')}] 已发送 SSH_SUSPEND_START_REQ (会话 ID: ${sessionId})`); + console.log(`[${t('term.sshSuspend')}] 已发送 SSH_MARK_FOR_SUSPEND 请求 (会话 ID: ${sessionId})`); + // 前端在发送此请求后,会话应保持活动状态,直到用户关闭标签页或网络断开。 + // 后端会在 WebSocket 关闭时处理实际的挂起。 + // 用户界面上可以给一个提示,表明“此会话已标记,关闭后将尝试挂起”。 + useUiNotificationsStore().addNotification({ + type: 'info', + message: t('sshSuspend.notifications.markedForSuspendInfo', { id: sessionId.slice(0,8) }), + timeout: 5000, // +++ 修改:duration -> timeout +++ + }); + } else { - console.warn(`[${t('term.sshSuspend')}] 未找到会话或 WebSocket 管理器 (会话 ID: ${sessionId}),无法启动挂起。`); + console.warn(`[${t('term.sshSuspend')}] 未找到会话或 WebSocket 管理器 (会话 ID: ${sessionId}),无法请求标记挂起。`); + useUiNotificationsStore().addNotification({ type: 'error', message: t('sshSuspend.notifications.sessionNotFoundError') }); } }; @@ -118,18 +130,90 @@ export const fetchSuspendedSshSessions = async (): Promise => { * 请求恢复指定的挂起 SSH 会话 * @param suspendSessionId 要恢复的挂起会话的 ID */ -export const resumeSshSession = (suspendSessionId: string): void => { - const wsManager = getActiveWsManager(); - if (wsManager) { - const newFrontendSessionId = uuidv4(); // 为恢复的会话生成新的前端 ID +export const resumeSshSession = async (suspendSessionId: string): Promise => { + const uiNotificationsStore = useUiNotificationsStore(); + const connectionsStore = useConnectionsStore(); + // const { t } = useI18n(); // t 已经在模块顶部定义 + + const sessionToResumeInfo = suspendedSshSessions.value.find(s => s.suspendSessionId === suspendSessionId); + if (!sessionToResumeInfo) { + console.error(`[${t('term.sshSuspend')}] 恢复操作失败:在挂起列表中未找到会话 ${suspendSessionId}`); + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.resumeErrorInfoNotFound', { id: suspendSessionId.slice(0, 8) }), + }); + return; + } + + const originalConnectionId = parseInt(sessionToResumeInfo.connectionId, 10); + if (isNaN(originalConnectionId)) { + console.error(`[${t('term.sshSuspend')}] 恢复操作失败:无效的原始连接 ID ${sessionToResumeInfo.connectionId}`); + uiNotificationsStore.addNotification({ type: 'error', message: t('sshSuspend.notifications.resumeErrorConnectionConfigNotFound', { id: sessionToResumeInfo.connectionId }) }); + return; + } + + const newFrontendSessionId = uuidv4(); // 为恢复的会话生成新的前端 ID + + try { + console.log(`[${t('term.sshSuspend')}] 准备恢复会话 ${suspendSessionId}。将创建新前端会话 ${newFrontendSessionId} 并连接 WebSocket。`); + + // 1. 调用 openNewSession 创建前端会话状态、WebSocket 连接等 + openNewSession( + originalConnectionId, + { connectionsStore, t }, // 传递依赖 + newFrontendSessionId // 将 newFrontendSessionId 作为 existingSessionId 传递 + ); + + // 2. 获取新创建会话的 wsManager + const newSessionState = sessions.value.get(newFrontendSessionId); + if (!newSessionState || !newSessionState.wsManager) { + console.error(`[${t('term.sshSuspend')}] 调用 openNewSession 后未能获取会话 ${newFrontendSessionId} 或其 wsManager。`); + uiNotificationsStore.addNotification({ type: 'error', message: t('sshSuspend.notifications.resumeErrorGeneric', { error: '无法初始化新会话界面组件' }) }); + return; + } + const wsManager = newSessionState.wsManager; + + // 3. 等待 WebSocket 连接成功 + const MAX_WAIT_ITERATIONS = 25; // 25 * 200ms = 5 seconds + let iterations = 0; + while (!wsManager.isConnected.value && iterations < MAX_WAIT_ITERATIONS) { + await new Promise(resolve => setTimeout(resolve, 200)); + iterations++; + } + + if (!wsManager.isConnected.value) { + console.error(`[${t('term.sshSuspend')}] 新创建的会话 ${newFrontendSessionId} 的 WebSocket 未能连接。无法发送恢复请求。`); + uiNotificationsStore.addNotification({ type: 'error', message: t('sshSuspend.notifications.resumeErrorGeneric', { error: '无法连接到服务器以恢复会话' }) }); + if (sessions.value.has(newFrontendSessionId)) { + closeSession(newFrontendSessionId); // 清理未成功连接的会话 + } + return; + } + + // 4. 发送恢复请求 + console.log(`[${t('term.sshSuspend')}] 会话 ${newFrontendSessionId} 的 WebSocket 已连接,准备发送恢复请求。`); const message: SshSuspendResumeReqMessage = { type: 'SSH_SUSPEND_RESUME_REQUEST', payload: { suspendSessionId, newFrontendSessionId }, }; + // console.log(`[${t('term.sshSuspend')}] resumeSshSession: 准备通过 wsManager (会话 ${newFrontendSessionId}) 发送消息: ${JSON.stringify(message)}`); wsManager.sendMessage(message); - console.log(`[${t('term.sshSuspend')}] 已发送 SSH_SUSPEND_RESUME_REQ (挂起 ID: ${suspendSessionId}, 新前端 ID: ${newFrontendSessionId})`); - } else { - console.warn(`[${t('term.sshSuspend')}] 恢复会话失败 (挂起 ID: ${suspendSessionId}):无可用 WebSocket 连接。`); + // console.log(`[${t('term.sshSuspend')}] resumeSshSession: 已调用 wsManager.sendMessage 发送 SSH_SUSPEND_RESUME_REQ (挂起 ID: ${suspendSessionId}, 新前端ID: ${newFrontendSessionId})`); + + // 后续流程由 handleSshSuspendResumedNotif 处理 + // 它会使用 newFrontendSessionId,并将 isResuming 标记设置到这个会话上。 + // 成功后,它内部应该会调用 fetchSuspendedSshSessions() 来更新列表。 + + } catch (error) { + console.error(`[${t('term.sshSuspend')}] 恢复会话 ${suspendSessionId} 过程中发生顶层错误:`, error); + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.resumeErrorGeneric', { error: String(error) }), + }); + // 如果 newFrontendSessionId 对应的会话已创建但恢复失败,也需要清理 + if (sessions.value.has(newFrontendSessionId)) { + closeSession(newFrontendSessionId); + } } }; @@ -213,24 +297,37 @@ export const editSshSessionName = (suspendSessionId: string, customName: string) // --- S2C Message Handlers --- -const handleSshSuspendStartedResp = (payload: SshSuspendStartedRespPayload): void => { +// 旧的 handleSshSuspendStartedResp 不再需要,因为流程已改变 +// const handleSshSuspendStartedResp = (payload: SshSuspendStartedRespPayload): void => { ... }; + +const handleSshMarkedForSuspendAck = (payload: SshMarkedForSuspendAckPayload): void => { const uiNotificationsStore = useUiNotificationsStore(); - console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_STARTED_RESP:`, payload); + console.log(`[${t('term.sshSuspend')}] 接到 SSH_MARKED_FOR_SUSPEND_ACK:`, payload); if (payload.success) { - uiNotificationsStore.addNotification({ - type: 'success', - message: t('sshSuspend.notifications.suspendStartedSuccess', { id: payload.suspendSessionId.slice(0, 8) }), - }); - // 成功后关闭原会话标签页 - closeSessionAction(payload.frontendSessionId); - // 刷新挂起列表 (可选,或者等待列表更新通知) - fetchSuspendedSshSessions(); + // 标记成功,用户可以继续使用会话,关闭时会自动尝试挂起。 + // requestStartSshSuspend 中已经给过一个提示了。 + // 这里可以再给一个更持久的提示,或者更新UI状态(例如在标签页上加个小图标) + // uiNotificationsStore.addNotification({ + // type: 'success', + // message: t('sshSuspend.notifications.markedForSuspendSuccess', { id: payload.sessionId.slice(0,8) }), + // }); + // 注意:此时不关闭会话,也不刷新挂起列表。实际挂起发生在后端WebSocket断开时。 + // 可以在 sessions.value 中对应会话的状态里加一个标记 isMarkedForSuspend = true + const session = sessions.value.get(payload.sessionId); + if (session) { + session.isMarkedForSuspend = true; // 假设 SessionState 有此字段 + } + } else { uiNotificationsStore.addNotification({ type: 'error', - message: t('sshSuspend.notifications.suspendStartedError', { error: payload.error || t('term.unknownError') }), + message: t('sshSuspend.notifications.markForSuspendError', { error: payload.error || t('term.unknownError') }), }); - console.error(`[${t('term.sshSuspend')}] 挂起失败 (前端会话 ID: ${payload.frontendSessionId}): ${payload.error}`); + console.error(`[${t('term.sshSuspend')}] 标记会话 ${payload.sessionId} 失败: ${payload.error}`); + const session = sessions.value.get(payload.sessionId); + if (session) { + session.isMarkedForSuspend = false; // 确保标记被清除 + } } }; @@ -242,61 +339,53 @@ const handleSshSuspendListResponse = (payload: SshSuspendListResponsePayload): v const handleSshSuspendResumedNotif = async (payload: SshSuspendResumedNotifPayload): Promise => { const uiNotificationsStore = useUiNotificationsStore(); - const connectionsStore = useConnectionsStore(); console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_RESUMED_NOTIF:`, payload); if (payload.success) { const suspendedSession = suspendedSshSessions.value.find(s => s.suspendSessionId === payload.suspendSessionId); + // suspendedSession 主要用于显示通知的友好名称。如果找不到,恢复流程仍可继续,但通知可能不那么具体。 if (!suspendedSession) { - console.error(`[${t('term.sshSuspend')}] 找不到要恢复的挂起会话信息 (ID: ${payload.suspendSessionId})`); - uiNotificationsStore.addNotification({ - type: 'error', - message: t('sshSuspend.notifications.resumeErrorInfoNotFound', { id: payload.suspendSessionId.slice(0, 8) }), - }); - return; - } - - // 从 connectionsStore 获取原始连接信息 - // 注意:这里假设 suspendedSession.originalConnectionInfo 存储了足够的信息,或者至少有 originalConnectionId - const connectionToFindId = parseInt(suspendedSession.connectionId, 10); - const connectionInfo = connectionsStore.connections.find(conn => conn.id === connectionToFindId); - if (!connectionInfo) { - console.error(`[${t('term.sshSuspend')}] 恢复会话失败:找不到原始连接配置 (ID: ${suspendedSession.connectionId})`); - uiNotificationsStore.addNotification({ - type: 'error', - message: t('sshSuspend.notifications.resumeErrorConnectionConfigNotFound', { id: suspendedSession.connectionId }), - }); - return; + console.warn(`[${t('term.sshSuspend')}] 处理 SSH_SUSPEND_RESUMED_NOTIF 时:在挂起列表中未找到会话 ${payload.suspendSessionId} 的详细信息。通知消息可能不完整。`); } try { - // 使用 openNewSession 创建会话 - openNewSession( - connectionInfo.id, // connectionId - { connectionsStore, t }, // dependencies - payload.newFrontendSessionId // existingSessionId - ); + // 会话应该已由 resumeSshSession action 通过调用 openNewSession 创建。 + // 它包含了所有必要的管理器和 WebSocket 连接。 + const sessionToUpdate = sessions.value.get(payload.newFrontendSessionId) as SessionState | undefined; - // 获取新创建的会话 - const newSession = sessions.value.get(payload.newFrontendSessionId) as SessionState | undefined; - - if (newSession && newSession.wsManager) { - // 标记会话为正在恢复 - newSession.isResuming = true; - // (可选) 如果需要存储原始挂起ID,可以在 SessionState 中添加 originalSuspendId 字段并在此设置 - // newSession.originalSuspendId = payload.suspendSessionId; - - console.log(`[${t('term.sshSuspend')}] 为恢复的会话 (新前端 ID: ${payload.newFrontendSessionId}) 创建/复用了新的会话实例。`); - // 激活新标签页 - activateSessionAction(payload.newFrontendSessionId); + if (!sessionToUpdate) { + console.error(`[${t('term.sshSuspend')}] 处理 SSH_SUSPEND_RESUMED_NOTIF 失败:未找到 ID 为 ${payload.newFrontendSessionId} 的预创建会话。`); uiNotificationsStore.addNotification({ - type: 'success', - message: t('sshSuspend.notifications.resumeSuccess', { name: suspendedSession.customSuspendName || suspendedSession.connectionName }), + type: 'error', + message: t('sshSuspend.notifications.resumeErrorGeneric', { error: '无法找到已初始化的恢复会话界面组件。' }), }); - // 后端会开始发送 SSH_OUTPUT_CACHED_CHUNK - } else { - throw new Error('通过 openNewSession 创建或获取新会话实例失败,或 WebSocket 管理器未初始化。'); + // 如果会话未找到,可能意味着 resumeSshSession 中的 openNewSession 失败或被意外清理 + return; } + + // 确保 wsManager 存在,理论上它应该由 openNewSession 创建 + if (!sessionToUpdate.wsManager) { + console.error(`[${t('term.sshSuspend')}] 会话 ${payload.newFrontendSessionId} 存在但缺少 wsManager。`); + uiNotificationsStore.addNotification({ type: 'error', message: '恢复失败:会话状态不完整。'}); + return; + } + + sessionToUpdate.isResuming = true; // 标记会话为正在恢复 + // (可选) 如果需要在 SessionState 中存储原始挂起ID: + // sessionToUpdate.originalSuspendId = payload.suspendSessionId; + + console.log(`[${t('term.sshSuspend')}] 会话 ${payload.newFrontendSessionId} 已标记为正在恢复。`); + activateSessionAction(payload.newFrontendSessionId); // 激活标签页 + + let notificationName = t('sshSuspend.notifications.defaultSessionName'); // 使用 i18n 获取默认名 + if (suspendedSession) { + notificationName = suspendedSession.customSuspendName || suspendedSession.connectionName || notificationName; + } + uiNotificationsStore.addNotification({ + type: 'success', + message: t('sshSuspend.notifications.resumeSuccess', { name: notificationName }), + }); + // 后端会通过与此 sessionToUpdate.wsManager 关联的 WebSocket 连接发送 SSH_OUTPUT_CACHED_CHUNK } catch (error) { console.error(`[${t('term.sshSuspend')}] 处理会话恢复通知时出错:`, error); uiNotificationsStore.addNotification({ @@ -305,32 +394,51 @@ const handleSshSuspendResumedNotif = async (payload: SshSuspendResumedNotifPaylo }); } // 成功恢复后,从挂起列表中移除 (或者等 SSH_SUSPEND_ENTRY_REMOVED_RESP) - // fetchSuspendedSshSessions(); // 刷新列表 + fetchSuspendedSshSessions(); // 在这里主动刷新一次,确保列表更新 } else { uiNotificationsStore.addNotification({ type: 'error', message: t('sshSuspend.notifications.resumeErrorBackend', { error: payload.error || t('term.unknownError') }), }); - console.error(`[${t('term.sshSuspend')}] 恢复会话失败 (挂起 ID: ${payload.suspendSessionId}): ${payload.error}`); + console.error(`[${t('term.sshSuspend')}] 后端报告恢复会话失败 (挂起 ID: ${payload.suspendSessionId}): ${payload.error}`); + // 如果后端报告恢复失败,可能需要关闭由 resumeSshSession 创建的前端会话 + if (sessions.value.has(payload.newFrontendSessionId)) { + console.log(`[${t('term.sshSuspend')}] 因后端恢复失败,正在关闭前端会话 ${payload.newFrontendSessionId}`); + closeSession(payload.newFrontendSessionId); + } } }; const handleSshOutputCachedChunk = (payload: SshOutputCachedChunkPayload): void => { const session = sessions.value.get(payload.frontendSessionId) as SessionState | undefined; - if (session && session.terminalManager && session.terminalManager.terminalInstance.value) { // 检查 terminalInstance.value - // console.debug(`[${t('term.sshSuspend')}] (会话: ${payload.frontendSessionId}) 接到 SSH_OUTPUT_CACHED_CHUNK, isLast: ${payload.isLastChunk}`); - session.terminalManager.terminalInstance.value.write(payload.data); // 调用 terminalInstance.value.write + if (session && session.terminalManager) { + if (session.terminalManager.terminalInstance.value) { + // 终端实例已就绪,直接写入 + console.log('[SSH Suspend Frontend] Received cached chunk data (writing to terminal):', payload.data); + session.terminalManager.terminalInstance.value.write(payload.data); + } else { + // 终端实例尚未就绪,暂存输出 + if (!session.pendingOutput) { + session.pendingOutput = []; + } + console.log('[SSH Suspend Frontend] Received cached chunk data (buffering):', payload.data); + session.pendingOutput.push(payload.data); + // console.log(`[${t('term.sshSuspend')}] (会话: ${payload.frontendSessionId}) 终端实例未就绪,已暂存数据块 (长度: ${payload.data.length})。当前暂存块数: ${session.pendingOutput.length}`); + } + + // isLastChunk 逻辑应该在数据被处理(写入或暂存)后执行 if (payload.isLastChunk) { - console.log(`[${t('term.sshSuspend')}] (会话: ${payload.frontendSessionId}) 已接收所有缓存输出。`); - // 可选:在这里触发一个事件或状态,表明缓存输出已加载完毕 - // 例如,如果之前终端是只读/加载状态,现在可以解除 + console.log(`[${t('term.sshSuspend')}] (会话: ${payload.frontendSessionId}) 已接收所有缓存输出的最后一个数据块标记。`); if (session.isResuming === true) { - session.isResuming = false; - // 可能需要重新聚焦终端或进行其他 UI 更新 + // 如果终端实例还未就绪,isResuming 状态的解除可能需要等到 pendingOutput 被清空时 + // 但如果 isLastChunk 到了,至少可以认为后端数据发送完毕 + // 实际的 isResuming = false 最好在 pendingOutput 被写入终端后处理 + // 这里只记录日志,具体状态变更由 Terminal.vue 或相关 manager 负责 + console.log(`[${t('term.sshSuspend')}] (会话: ${payload.frontendSessionId}) isResuming 标记仍为 true,等待终端处理暂存数据(如有)。`); } } } else { - console.warn(`[${t('term.sshSuspend')}] 收到缓存数据块,但找不到对应会话、终端管理器或终端实例 (ID: ${payload.frontendSessionId})`); + console.warn(`[${t('term.sshSuspend')}] 收到缓存数据块,但找不到对应会话或其终端管理器 (ID: ${payload.frontendSessionId})`); } }; @@ -428,7 +536,8 @@ export const registerSshSuspendHandlers = (wsManager: WsManagerInstance): void = // 注意:wsManager.onMessage 返回一个注销函数,如果需要,可以收集它们并在会话关闭时调用。 // 但通常这些处理器会随 wsManager 实例的生命周期一起存在。 - wsManager.onMessage('SSH_SUSPEND_STARTED_RESP', (p: MessagePayload) => handleSshSuspendStartedResp(p as SshSuspendStartedRespPayload)); + // wsManager.onMessage('SSH_SUSPEND_STARTED_RESP', (p: MessagePayload) => handleSshSuspendStartedResp(p as SshSuspendStartedRespPayload)); // 已移除 + wsManager.onMessage('SSH_MARKED_FOR_SUSPEND_ACK', (p: MessagePayload) => handleSshMarkedForSuspendAck(p as SshMarkedForSuspendAckPayload)); // +++ 新增处理器 +++ wsManager.onMessage('SSH_SUSPEND_LIST_RESPONSE', (p: MessagePayload) => handleSshSuspendListResponse(p as SshSuspendListResponsePayload)); wsManager.onMessage('SSH_SUSPEND_RESUMED_NOTIF', (p: MessagePayload) => handleSshSuspendResumedNotif(p as SshSuspendResumedNotifPayload)); wsManager.onMessage('SSH_OUTPUT_CACHED_CHUNK', (p: MessagePayload) => handleSshOutputCachedChunk(p as SshOutputCachedChunkPayload)); diff --git a/packages/frontend/src/stores/session/types.ts b/packages/frontend/src/stores/session/types.ts index fc0ce72..9102f42 100644 --- a/packages/frontend/src/stores/session/types.ts +++ b/packages/frontend/src/stores/session/types.ts @@ -42,7 +42,9 @@ export interface SessionState { // --- 新增:命令输入框内容 --- commandInputContent: Ref; // 当前会话的命令输入框内容 isResuming?: boolean; // 新增:标记会话是否正在从挂起状态恢复 + isMarkedForSuspend?: boolean; // +++ 新增:标记会话是否已被用户请求标记为待挂起 +++ disposables?: (() => void)[]; // 新增:用于存储清理函数,例如取消注册消息处理器 + pendingOutput?: string[]; // 新增:用于暂存恢复会话时,在终端实例准备好之前收到的输出 } // 为标签栏定义包含状态的类型 diff --git a/packages/frontend/src/types/websocket.types.ts b/packages/frontend/src/types/websocket.types.ts index 632ee74..981b7da 100644 --- a/packages/frontend/src/types/websocket.types.ts +++ b/packages/frontend/src/types/websocket.types.ts @@ -24,6 +24,7 @@ import type { SuspendedSshSession } from './ssh-suspend.types'; // 路径: packa // --- Client to Server (C2S) Message Payloads --- export interface SshSuspendStartReqPayload { sessionId: string; + initialBuffer?: string; // Optional: content of the terminal buffer at the time of suspend } export interface SshSuspendResumeReqPayload { @@ -44,7 +45,17 @@ export interface SshSuspendEditNameReqPayload { customName: string; } +export interface SshMarkForSuspendReqPayload { // +++ 新增 +++ + sessionId: string; +} + // --- Server to Client (S2C) Message Payloads --- +export interface SshMarkedForSuspendAckPayload { // +++ 新增 +++ + sessionId: string; + success: boolean; + error?: string; +} + export interface SshSuspendStartedRespPayload { frontendSessionId: string; suspendSessionId: string; @@ -124,7 +135,17 @@ export interface SshSuspendEditNameReqMessage extends WebSocketMessage { payload: SshSuspendEditNameReqPayload; } +export interface SshMarkForSuspendReqMessage extends WebSocketMessage { // +++ 新增 +++ + type: 'SSH_MARK_FOR_SUSPEND'; + payload: SshMarkForSuspendReqPayload; +} + // --- Specific S2C Message Interfaces --- +export interface SshMarkedForSuspendAckMessage extends WebSocketMessage { // +++ 新增 +++ + type: 'SSH_MARKED_FOR_SUSPEND_ACK'; + payload: SshMarkedForSuspendAckPayload; +} + export interface SshSuspendStartedRespMessage extends WebSocketMessage { type: 'SSH_SUSPEND_STARTED'; payload: SshSuspendStartedRespPayload; @@ -172,9 +193,11 @@ export type SshSuspendC2SMessage = | SshSuspendResumeReqMessage | SshSuspendTerminateReqMessage | SshSuspendRemoveEntryReqMessage - | SshSuspendEditNameReqMessage; + | SshSuspendEditNameReqMessage + | SshMarkForSuspendReqMessage; // +++ 新增 +++ export type SshSuspendS2CMessage = + | SshMarkedForSuspendAckMessage // +++ 新增 +++ | SshSuspendStartedRespMessage | SshSuspendListResponseMessage | SshSuspendResumedNotifMessage diff --git a/packages/frontend/src/views/SuspendedSshSessionsView.vue b/packages/frontend/src/views/SuspendedSshSessionsView.vue index c0d7448..39ad9ea 100644 --- a/packages/frontend/src/views/SuspendedSshSessionsView.vue +++ b/packages/frontend/src/views/SuspendedSshSessionsView.vue @@ -238,10 +238,43 @@ const cancelEditingName = (session: SuspendedSshSessionUIData) => { }; -const resumeSession = (session: SuspendedSshSessionUIData) => { - // 实际应用中,newFrontendSessionId 可能需要由 sessionStore 或其他服务生成 - // const newFrontendSessionId = `new-session-${Date.now()}`; // newFrontendSessionId 由 action 内部生成 - sessionStore.resumeSshSession(session.suspendSessionId); // +++ 只传递 suspendSessionId +++ +const resumeSession = async (session: SuspendedSshSessionUIData) => { + console.log(`[SuspendedSshSessionsView] Attempting to resume session ID: ${session.suspendSessionId}, Name: ${session.customSuspendName || session.connectionName}`); + // 使用 JSON.parse(JSON.stringify()) 来记录会话对象的一个快照,避免在异步操作后因对象被修改而导致日志不准确 + console.log('[SuspendedSshSessionsView] Session details snapshot:', JSON.parse(JSON.stringify(session))); + + try { + // 假设 sessionStore.resumeSshSession 返回一个 Promise。 + // 如果它不返回 Promise (例如,它是一个同步的 action dispatch),await 仍然是安全的,result 将会是 undefined。 + // 为了获取详细信息(如是否真正恢复、历史日志),sessionStore.resumeSshSession 可能需要被修改以返回一个包含这些信息的对象。 + const result = await sessionStore.resumeSshSession(session.suspendSessionId); + + console.log('[SuspendedSshSessionsView] Call to sessionStore.resumeSshSession completed.'); + + // 检查 result 是否是包含期望信息的对象结构 + // @ts-ignore (因为我们不确定 result 的确切类型,并且这是在 Vue 文件中) + if (result && typeof result === 'object' && ('isResumed' in result || 'historicalOutput' in result || 'message' in result)) { + console.log('[SuspendedSshSessionsView] Result from resumeSshSession:', result); + // @ts-ignore + console.log(`[SuspendedSshSessionsView] Is session truly resumed (based on backend response)? : ${result.isResumed ? 'Yes, existing session resumed.' : 'No, a new session was likely opened (or status unknown from response).'}`); + // @ts-ignore + console.log('[SuspendedSshSessionsView] Historical terminal log from backend:', result.historicalOutput || 'Not provided or empty.'); + // @ts-ignore + if (result.message) { + // @ts-ignore + console.log('[SuspendedSshSessionsView] Backend message:', result.message); + } + } else { + console.log('[SuspendedSshSessionsView] sessionStore.resumeSshSession did not return the expected detailed information object (e.g., { isResumed: boolean, historicalOutput?: string, message?: string }). The action was dispatched.'); + console.log('[SuspendedSshSessionsView] To get client-side confirmation of session state and historical logs, the sessionStore.resumeSshSession action might need to be updated to return this data.'); + console.log('[SuspendedSshSessionsView] For now, please check browser developer console (network tab for backend responses) or backend logs for details on session restoration and historical log loading.'); + if (result !== undefined) { + console.log('[SuspendedSshSessionsView] Actual value returned by resumeSshSession (if any):', result); + } + } + } catch (error) { + console.error(`[SuspendedSshSessionsView] Error during resumeSession for ${session.suspendSessionId}:`, error); + } }; const removeSession = (session: SuspendedSshSessionUIData) => {