diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index bc9ae36..96ff7d9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -52,6 +52,7 @@ import terminalThemeRoutes from './terminal-themes/terminal-theme.routes'; import appearanceRoutes from './appearance/appearance.routes'; import sshKeysRouter from './ssh_keys/ssh_keys.routes'; // +++ Import SSH Key routes +++ import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes'; // +++ Import Quick Command Tag routes +++ +import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes'; // +++ Import SSH Suspend routes +++ import { initializeWebSocket } from './websocket'; import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; @@ -271,6 +272,7 @@ const startServer = () => { app.use('/api/v1/appearance', appearanceRoutes); app.use('/api/v1/ssh-keys', sshKeysRouter); // +++ Register SSH Key routes +++ app.use('/api/v1/quick-command-tags', quickCommandTagRoutes); // +++ Register Quick Command Tag routes +++ + app.use('/api/v1/ssh-suspend', sshSuspendRouter); // +++ Register SSH Suspend routes +++ // 状态检查接口 app.get('/api/v1/status', (req: Request, res: Response) => { diff --git a/packages/backend/src/services/settings.service.ts b/packages/backend/src/services/settings.service.ts index 2e9df9f..f059a77 100644 --- a/packages/backend/src/services/settings.service.ts +++ b/packages/backend/src/services/settings.service.ts @@ -382,7 +382,7 @@ export const settingsService = { const validPaneNames: Set = new Set([ 'connections', 'terminal', 'commandBar', 'fileManager', 'editor', 'statusMonitor', 'commandHistory', 'quickCommands', - 'dockerManager' + 'dockerManager', 'suspendedSshSessions' // 添加 "suspendedSshSessions" ]); const validatePaneArray = (arr: any[], side: string) => { diff --git a/packages/backend/src/services/ssh-suspend.service.ts b/packages/backend/src/services/ssh-suspend.service.ts new file mode 100644 index 0000000..86a092e --- /dev/null +++ b/packages/backend/src/services/ssh-suspend.service.ts @@ -0,0 +1,355 @@ +import { Client, Channel, ClientChannel } from 'ssh2'; +import { EventEmitter } from 'events'; +import { v4 as uuidv4 } from 'uuid'; +import { + SuspendSessionDetails, + SuspendedSessionsMap, + BackendSshStatus, + SuspendedSessionInfo, +} from '../types/ssh-suspend.types'; +import { temporaryLogStorageService, TemporaryLogStorageService } from './temporary-log-storage.service'; +import { clientStates } from '../websocket/state'; // +++ 导入 clientStates +++ + +/** + * SshSuspendService 负责管理所有用户的挂起 SSH 会话的生命周期。 + */ +export class SshSuspendService extends EventEmitter { + private suspendedSessions: SuspendedSessionsMap = new Map(); + private readonly logStorageService: TemporaryLogStorageService; + + constructor(logStorage?: TemporaryLogStorageService) { + super(); // 调用 EventEmitter 的构造函数 + this.logStorageService = logStorage || temporaryLogStorageService; + // TODO: 考虑在服务启动时从日志目录加载持久化的 'disconnected_by_backend' 会话信息。 + // 这需要日志文件本身包含可解析的元数据。 + } + + /** + * 获取用户特定的会话映射,如果不存在则创建。 + * @param userId 用户ID。 + * @returns 该用户的 Map。 + */ + private getUserSessions(userId: number): Map { // userId: string -> number + if (!this.suspendedSessions.has(userId)) { + this.suspendedSessions.set(userId, new Map()); + } + return this.suspendedSessions.get(userId)!; + } + + /** + * 启动指定 SSH 会话的挂起模式。 + * @param userId 用户ID。 + * @param originalSessionId 原始会话ID。 + * @param sshClient SSH 客户端实例。 + * @param channel SSH 通道实例。 + * @param connectionName 连接名称。 + * @param connectionId 连接ID。 + * @param customSuspendName 可选的自定义挂起名称。 + * @returns Promise 返回生成的 suspendSessionId。 + */ + async startSuspend( + userId: number, // userId: string -> number + originalSessionId: string, + sshClient: Client, + channel: ClientChannel, // 更新为 ClientChannel + connectionName: string, + connectionId: string, + customSuspendName?: string, + ): Promise { + 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' 事件 + + // 对于 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, + connectionName, + connectionId, + suspendStartTime: new Date().toISOString(), + customSuspendName, + backendSshStatus: 'hanging', + originalSessionId, + userId, + }; + + userSessions.set(suspendSessionId, sessionDetails); + + // +++ 更新 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); + }); + } + }); + + const handleUnexpectedClose = () => { + const currentSession = userSessions.get(suspendSessionId); + if (currentSession && currentSession.backendSshStatus === 'hanging') { + const reason = 'SSH connection closed or errored.'; + console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH 连接意外断开。原因: ${reason}`); + currentSession.backendSshStatus = 'disconnected_by_backend'; + currentSession.disconnectionTimestamp = new Date().toISOString(); + + this.removeChannelListeners(channel, sshClient); // 使用辅助方法移除 + + // 发出事件通知 WebSocket 层 + this.emit('sessionAutoTerminated', { + userId: currentSession.userId, // 使用存储在 sessionDetails 中的 userId + suspendSessionId, + reason + }); + } + }; + + channel.on('close', handleUnexpectedClose); + channel.on('error', (err: Error) => { + console.error(`[用户: ${userId}, 会话: ${suspendSessionId}] 通道错误:`, err); + handleUnexpectedClose(); + }); + sshClient.on('error', (err: Error) => { + console.error(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH客户端错误:`, err); + handleUnexpectedClose(); + }); + sshClient.on('end', () => { // 'end' 通常是正常关闭,但也需要处理 + console.log(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH客户端连接结束。`); + handleUnexpectedClose(); // 如果是意外的,则标记为 disconnected + }); + + + return suspendSessionId; + } + + /** + * 辅助方法:移除会话相关的事件监听器。 + */ + private removeChannelListeners(channel: Channel, sshClient: Client): void { + channel.removeAllListeners('data'); + channel.removeAllListeners('close'); + channel.removeAllListeners('error'); + sshClient.removeAllListeners('error'); + sshClient.removeAllListeners('end'); + } + + + /** + * 列出指定用户的所有挂起会话(包括活跃和已断开的)。 + * 目前主要从内存中获取信息。 + * @param userId 用户ID。 + * @returns Promise 挂起会话信息的数组。 + */ + async listSuspendedSessions(userId: number): Promise { // userId: string -> number + const userSessions = this.getUserSessions(userId); + const sessionsInfo: SuspendedSessionInfo[] = []; + + for (const [suspendSessionId, details] of userSessions.entries()) { + sessionsInfo.push({ + suspendSessionId, + connectionName: details.connectionName, + connectionId: details.connectionId, + suspendStartTime: details.suspendStartTime, + customSuspendName: details.customSuspendName, + backendSshStatus: details.backendSshStatus, + disconnectionTimestamp: details.disconnectionTimestamp, + }); + } + // TODO: 增强此方法以从日志目录恢复 'disconnected_by_backend' 的会话状态, + // 这需要日志文件包含元数据。 + return sessionsInfo; + } + + /** + * 恢复指定的挂起会话。 + * @param userId 用户ID。 + * @param suspendSessionId 要恢复的挂起会话ID。 + * @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> { + const userSessions = this.getUserSessions(userId); + const session = userSessions.get(suspendSessionId); + + if (!session || session.backendSshStatus !== 'hanging') { + console.warn(`[用户: ${userId}] 尝试恢复的会话 ${suspendSessionId} 不存在或状态不正确 (${session?.backendSshStatus})。`); + return null; + } + + // 停止监听旧通道事件 + this.removeChannelListeners(session.channel, session.sshClient); + + const logData = await this.logStorageService.readLog(suspendSessionId); + + // 在从 userSessions 删除会话之前,保存需要返回的会话详细信息 + const { sshClient, channel, connectionName, connectionId: originalConnectionId } = session; + + userSessions.delete(suspendSessionId); + await this.logStorageService.deleteLog(suspendSessionId); + + console.log(`[用户: ${userId}] 挂起会话 ${suspendSessionId} 已成功恢复。`); + return { + sshClient, + channel, + logData, + connectionName, + originalConnectionId + }; + } + + /** + * 终止一个活跃的挂起会话。 + * @param userId 用户ID。 + * @param suspendSessionId 要终止的挂起会话ID。 + * @returns Promise 操作是否成功。 + */ + async terminateSuspendedSession(userId: number, suspendSessionId: string): Promise { // userId: string -> number + const userSessions = this.getUserSessions(userId); + const session = userSessions.get(suspendSessionId); + + if (!session || session.backendSshStatus !== 'hanging') { + console.warn(`[用户: ${userId}] 尝试终止的会话 ${suspendSessionId} 不存在或不是活跃状态 (${session?.backendSshStatus})。`); + // 如果会话已断开,但记录还在,也应该能被“终止”(即移除) + if(session && session.backendSshStatus === 'disconnected_by_backend'){ + userSessions.delete(suspendSessionId); + await this.logStorageService.deleteLog(suspendSessionId); + console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} 已通过终止操作移除。`); + return true; + } + return false; + } + + this.removeChannelListeners(session.channel, session.sshClient); + + try { + session.channel.close(); // 尝试优雅关闭 + } catch (e) { + console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] 关闭channel时出错:`, e); + } + try { + session.sshClient.end(); // 尝试优雅关闭 + } catch (e) { + console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] 关闭sshClient时出错:`, e); + } + + userSessions.delete(suspendSessionId); + await this.logStorageService.deleteLog(suspendSessionId); + + console.log(`[用户: ${userId}] 活跃的挂起会话 ${suspendSessionId} 已成功终止并移除。`); + return true; + } + + /** + * 移除一个已断开的挂起会话条目。 + * @param userId 用户ID。 + * @param suspendSessionId 要移除的挂起会话ID。 + * @returns Promise 操作是否成功。 + */ + async removeDisconnectedSessionEntry(userId: number, suspendSessionId: string): Promise { // userId: string -> number + const userSessions = this.getUserSessions(userId); + const session = userSessions.get(suspendSessionId); + + if (session && session.backendSshStatus === 'hanging') { + console.warn(`[用户: ${userId}] 尝试移除的会话 ${suspendSessionId} 仍处于活跃状态,请先终止。`); + return false; // 不允许直接移除活跃会话,应先终止 + } + + // 如果会话在内存中(不论状态),则删除 + if (session) { + userSessions.delete(suspendSessionId); + } + + // 总是尝试删除日志文件,因为它可能对应一个已不在内存中的断开会话 + try { + await this.logStorageService.deleteLog(suspendSessionId); + console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} 的日志已删除 (内存中状态: ${session ? session.backendSshStatus : '不在内存'})。`); + return true; + } catch (error) { + console.error(`[用户: ${userId}] 删除会话 ${suspendSessionId} 的日志文件失败:`, error); + // 即便日志删除失败,如果内存条目已删,也算部分成功。但严格来说应返回false。 + // 如果 session 不在内存中,但日志删除成功,也算成功。 + return false; + } + } + + /** + * 编辑挂起会话的自定义名称。 + * 目前仅更新内存中的名称。 + * @param userId 用户ID。 + * @param suspendSessionId 挂起会话ID。 + * @param newCustomName 新的自定义名称。 + * @returns Promise 操作是否成功。 + */ + async editSuspendedSessionName(userId: number, suspendSessionId: string, newCustomName: string): Promise { // userId: string -> number + const userSessions = this.getUserSessions(userId); + const session = userSessions.get(suspendSessionId); + + if (!session) { + console.warn(`[用户: ${userId}] 尝试编辑名称的会话 ${suspendSessionId} 不存在。`); + return false; + } + + session.customSuspendName = newCustomName; + console.log(`[用户: ${userId}] 挂起会话 ${suspendSessionId} 的自定义名称已更新为: ${newCustomName}`); + // TODO: 如果设计要求将自定义名称持久化到日志文件的元数据部分, + // 此处需要添加更新日志文件的逻辑。这可能涉及读取、修改元数据、然后重写文件。 + return true; + } + + /** + * 处理特定会话的 SSH 连接意外断开。 + * 此方法主要由内部事件监听器调用。 + * @param userId 用户ID。 + * @param suspendSessionId 发生断开的会话ID。 + */ + public handleUnexpectedDisconnection(userId: number, suspendSessionId: string): void { // userId: string -> number + const userSessions = this.getUserSessions(userId); + const session = userSessions.get(suspendSessionId); + + if (session && session.backendSshStatus === 'hanging') { + const reason = 'Unexpected disconnection handled by SshSuspendService.'; + session.backendSshStatus = 'disconnected_by_backend'; + session.disconnectionTimestamp = new Date().toISOString(); + this.removeChannelListeners(session.channel, session.sshClient); // 移除监听器 + console.log(`[用户: ${userId}] 会话 ${suspendSessionId} 状态更新为 'disconnected_by_backend'。原因: ${reason}`); + + this.emit('sessionAutoTerminated', { + userId: session.userId, + suspendSessionId, + reason + }); + // 确保所有已缓冲的日志已尝试写入 (通常由 'data' 事件处理,这里是最终状态确认) + } + } +} + +// 单例模式导出 +export const sshSuspendService = new SshSuspendService(); \ No newline at end of file diff --git a/packages/backend/src/services/temporary-log-storage.service.ts b/packages/backend/src/services/temporary-log-storage.service.ts new file mode 100644 index 0000000..2b4978b --- /dev/null +++ b/packages/backend/src/services/temporary-log-storage.service.ts @@ -0,0 +1,124 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const MAX_LOG_SIZE_BYTES = 100 * 1024 * 1024; // 100MB +const LOG_DIRECTORY = './data/temp_suspended_ssh_logs/'; + +/** + * TemporaryLogStorageService负责管理临时日志文件的原子化读、写、删除及轮替操作。 + */ +export class TemporaryLogStorageService { + constructor() { + this.ensureLogDirectoryExists(); + } + + /** + * 确保日志目录存在,如果不存在则创建它。 + */ + async ensureLogDirectoryExists(): Promise { + try { + await fs.mkdir(LOG_DIRECTORY, { recursive: true }); + // console.log(`日志目录 '${LOG_DIRECTORY}' 已确保存在。`); + } catch (error) { + console.error(`创建日志目录 '${LOG_DIRECTORY}' 失败:`, error); + // 在实际应用中,这里可能需要更健壮的错误处理 + } + } + + private getLogFilePath(suspendSessionId: string): string { + return path.join(LOG_DIRECTORY, `${suspendSessionId}.log`); + } + + /** + * 将数据写入指定挂起会话的日志文件。 + * 如果文件大小超过MAX_LOG_SIZE_BYTES,将采取轮替策略(清空并从头开始写)。 + * @param suspendSessionId - 挂起会话的ID。 + * @param data - 要写入的数据。 + */ + async writeToLog(suspendSessionId: string, data: string): Promise { + const filePath = this.getLogFilePath(suspendSessionId); + try { + await this.ensureLogDirectoryExists(); // 确保目录存在 + let stat; + try { + stat = await fs.stat(filePath); + } catch (e: any) { + if (e.code !== 'ENOENT') { + throw e; + } + // 文件不存在,是正常情况,后续会创建 + } + + if (stat && stat.size >= MAX_LOG_SIZE_BYTES) { + // 文件过大,执行轮替策略:清空文件 + console.log(`日志文件 '${filePath}' 大小达到 ${MAX_LOG_SIZE_BYTES / (1024 * 1024)}MB,执行轮替(清空)。`); + await fs.writeFile(filePath, data, 'utf8'); // 清空并写入新数据 + } else { + await fs.appendFile(filePath, data, 'utf8'); + } + } catch (error) { + console.error(`写入日志文件 '${filePath}' 失败:`, error); + throw error; // 重新抛出错误,让调用者处理 + } + } + + /** + * 读取指定挂起会话的日志文件内容。 + * @param suspendSessionId - 挂起会话的ID。 + * @returns 返回日志文件的内容。如果文件不存在,则返回空字符串。 + */ + async readLog(suspendSessionId: string): Promise { + const filePath = this.getLogFilePath(suspendSessionId); + try { + const data = await fs.readFile(filePath, 'utf8'); + return data; + } catch (error: any) { + if (error.code === 'ENOENT') { + // console.log(`日志文件 '${filePath}' 不存在,返回空内容。`); + return ''; // 文件不存在,通常意味着没有日志 + } + console.error(`读取日志文件 '${filePath}' 失败:`, error); + throw error; + } + } + + /** + * 删除指定挂起会话的日志文件。 + * @param suspendSessionId - 挂起会话的ID。 + */ + async deleteLog(suspendSessionId: string): Promise { + const filePath = this.getLogFilePath(suspendSessionId); + try { + await fs.unlink(filePath); + // console.log(`日志文件 '${filePath}' 已成功删除。`); + } catch (error: any) { + if (error.code === 'ENOENT') { + // console.warn(`尝试删除日志文件 '${filePath}' 时发现文件已不存在,操作忽略。`); + return; // 文件不存在,无需操作 + } + console.error(`删除日志文件 '${filePath}' 失败:`, error); + throw error; + } + } + + /** + * 列出日志目录中的所有日志文件名(不含扩展名,即suspendSessionId)。 + * 这可以用于 `SshSuspendService` 初始化时加载已断开的会话。 + * @returns 返回包含所有 suspendSessionId 的数组。 + */ + async listLogFiles(): Promise { + try { + await this.ensureLogDirectoryExists(); + const files = await fs.readdir(LOG_DIRECTORY); + return files + .filter(file => file.endsWith('.log')) + .map(file => file.replace(/\.log$/, '')); + } catch (error) { + console.error(`列出日志目录 '${LOG_DIRECTORY}' 中的文件失败:`, error); + return []; // 发生错误时返回空数组 + } + } +} + +// 单例模式导出 +export const temporaryLogStorageService = new TemporaryLogStorageService(); \ No newline at end of file diff --git a/packages/backend/src/ssh-suspend/ssh-suspend.controller.ts b/packages/backend/src/ssh-suspend/ssh-suspend.controller.ts new file mode 100644 index 0000000..ebff402 --- /dev/null +++ b/packages/backend/src/ssh-suspend/ssh-suspend.controller.ts @@ -0,0 +1,107 @@ +import { Request, Response } from 'express'; +import { sshSuspendService } from '../services/ssh-suspend.service'; // 导入单例服务 +import { SuspendedSessionInfo } from '../types/ssh-suspend.types'; // 导入类型 + +export class SshSuspendController { + // private sshSuspendService: SshSuspendService; // 不再需要,直接使用导入的单例 + + constructor() { + // this.sshSuspendService = new SshSuspendService(); // 不再需要实例化 + // 绑定方法到当前实例,以确保 'this' 上下文正确 + this.getSuspendedSshSessions = this.getSuspendedSshSessions.bind(this); + this.terminateAndRemoveSession = this.terminateAndRemoveSession.bind(this); + this.removeSessionEntry = this.removeSessionEntry.bind(this); + } + + public async getSuspendedSshSessions(req: Request, res: Response): Promise { + try { + const userId = req.session.userId; + + if (!userId) { + res.status(401).json({ message: 'Unauthorized. User ID not found in session.' }); + return; + } + + console.log(`[SshSuspendController] getSuspendedSshSessions called for user ID: ${userId}`); + + const sessions: SuspendedSessionInfo[] = await sshSuspendService.listSuspendedSessions(userId); + res.status(200).json(sessions); + } catch (error) { + console.error(`[SshSuspendController] Error fetching suspended SSH sessions for user ID: ${req.session.userId}:`, error); + if (error instanceof Error) { + res.status(500).json({ message: 'Failed to fetch suspended SSH sessions', error: error.message }); + } else { + res.status(500).json({ message: 'Failed to fetch suspended SSH sessions', error: 'Unknown error' }); + } + } + } // Closes getSuspendedSshSessions + + public async terminateAndRemoveSession(req: Request, res: Response): Promise { + try { + const userId = req.session.userId; + const { suspendSessionId } = req.params; + + if (!userId) { + res.status(401).json({ message: 'Unauthorized. User ID not found in session.' }); + return; + } + if (!suspendSessionId) { + res.status(400).json({ message: 'Bad Request. suspendSessionId parameter is missing.' }); + return; + } + + console.log(`[SshSuspendController] terminateAndRemoveSession called for user ID: ${userId}, suspendSessionId: ${suspendSessionId}`); + + const success = await sshSuspendService.terminateSuspendedSession(userId, suspendSessionId); + if (success) { + res.status(200).json({ message: `Suspended session ${suspendSessionId} terminated and removed successfully.` }); + } else { + // The service logs warnings for non-existent or wrong state sessions, + // which might be a 404 or a 409 depending on the exact cause. + // For simplicity, returning 404 if not successful, assuming it means "not found or not in correct state to terminate". + res.status(404).json({ message: `Failed to terminate and remove session ${suspendSessionId}. It might not exist or not be in a 'hanging' state.` }); + } + } catch (error) { + console.error(`[SshSuspendController] Error terminating session for user ID: ${req.session.userId}, suspendSessionId: ${req.params.suspendSessionId}:`, error); + if (error instanceof Error) { + res.status(500).json({ message: 'Failed to terminate suspended session', error: error.message }); + } else { + res.status(500).json({ message: 'Failed to terminate suspended session', error: 'Unknown error' }); + } + } + } + + public async removeSessionEntry(req: Request, res: Response): Promise { + try { + const userId = req.session.userId; + const { suspendSessionId } = req.params; + + if (!userId) { + res.status(401).json({ message: 'Unauthorized. User ID not found in session.' }); + return; + } + if (!suspendSessionId) { + res.status(400).json({ message: 'Bad Request. suspendSessionId parameter is missing.' }); + return; + } + + console.log(`[SshSuspendController] removeSessionEntry called for user ID: ${userId}, suspendSessionId: ${suspendSessionId}`); + + const success = await sshSuspendService.removeDisconnectedSessionEntry(userId, suspendSessionId); + if (success) { + res.status(200).json({ message: `Suspended session entry ${suspendSessionId} removed successfully.` }); + } else { + // Similar to terminate, if not successful, it might be due to various reasons logged by the service. + // Returning 404 if not successful, assuming it means "not found or not in correct state to remove". + res.status(404).json({ message: `Failed to remove session entry ${suspendSessionId}. It might not exist or was still 'hanging'.` }); + } + } catch (error) { + console.error(`[SshSuspendController] Error removing session entry for user ID: ${req.session.userId}, suspendSessionId: ${req.params.suspendSessionId}:`, error); + if (error instanceof Error) { + res.status(500).json({ message: 'Failed to remove suspended session entry', error: error.message }); + } else { + res.status(500).json({ message: 'Failed to remove suspended session entry', error: 'Unknown error' }); + } + } + } + } \ No newline at end of file diff --git a/packages/backend/src/ssh-suspend/ssh-suspend.routes.ts b/packages/backend/src/ssh-suspend/ssh-suspend.routes.ts new file mode 100644 index 0000000..e6b4d94 --- /dev/null +++ b/packages/backend/src/ssh-suspend/ssh-suspend.routes.ts @@ -0,0 +1,30 @@ +import express from 'express'; +import { SshSuspendController } from './ssh-suspend.controller'; +import { isAuthenticated } from '../auth/auth.middleware'; // 取消注释:如果需要认证 + +const router = express.Router(); +const sshSuspendController = new SshSuspendController(); + +// 定义获取挂起 SSH 会话列表的路由 +// 路径将是 /api/v1/ssh-suspend/suspended-sessions (因为基础路径是 /api/v1/ssh-suspend) +router.get( + '/suspended-sessions', + isAuthenticated, // 取消注释:添加认证中间件 + sshSuspendController.getSuspendedSshSessions +); + +// Route to terminate an active 'hanging' suspended session and remove its entry +router.delete( + '/terminate/:suspendSessionId', + isAuthenticated, + sshSuspendController.terminateAndRemoveSession +); + +// Route to remove an already 'disconnected_by_backend' suspended session entry +router.delete( + '/entry/:suspendSessionId', + isAuthenticated, + sshSuspendController.removeSessionEntry +); + +export default router; \ No newline at end of file diff --git a/packages/backend/src/types/settings.types.ts b/packages/backend/src/types/settings.types.ts index cb41e63..7e36826 100644 --- a/packages/backend/src/types/settings.types.ts +++ b/packages/backend/src/types/settings.types.ts @@ -1,7 +1,7 @@ // packages/backend/src/types/settings.types.ts // Define PaneName here as it's logically related to layout/sidebar settings -export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager'; +export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager' | 'suspendedSshSessions'; /** * 布局节点接口 (Mirrors frontend definition for backend use) diff --git a/packages/backend/src/types/ssh-suspend.types.ts b/packages/backend/src/types/ssh-suspend.types.ts new file mode 100644 index 0000000..f46cd29 --- /dev/null +++ b/packages/backend/src/types/ssh-suspend.types.ts @@ -0,0 +1,56 @@ +import { Client, Channel, ClientChannel } from 'ssh2'; + +/** + * 后端 SSH 会话状态 + * 'hanging': 会话正在挂起,SSH 连接活跃。 + * 'disconnected_by_backend': 后端检测到 SSH 连接意外断开。 + */ +export type BackendSshStatus = 'hanging' | 'disconnected_by_backend'; + +/** + * 挂起会话的详细信息接口 + */ +export interface SuspendSessionDetails { + /** SSH 客户端实例 */ + sshClient: Client; + /** SSH 通道实例 */ + channel: ClientChannel; // 使用更具体的 ClientChannel 类型 + /** 临时日志文件路径 */ + tempLogPath: string; + /** 连接名称 */ + connectionName: string; + /** 连接 ID */ + connectionId: string; + /** 挂起开始时间 (ISO 格式字符串) */ + suspendStartTime: string; + /** 用户自定义的挂起会话名称 */ + customSuspendName?: string; + /** 后端 SSH 会话状态 */ + backendSshStatus: BackendSshStatus; + /** 断开连接的时间戳 (ISO 格式字符串),仅当 backendSshStatus 为 'disconnected_by_backend' 时存在 */ + disconnectionTimestamp?: string; + /** 原始会话ID */ + originalSessionId: string; + /** 用户ID */ + userId: number; +} + +/** + * 用于存储在内存中的挂起会话映射 + * Key: userId + * Value: Map + */ +export type SuspendedSessionsMap = Map>; + +/** + * 用于API响应的挂起会话信息子集 + */ +export interface SuspendedSessionInfo { + suspendSessionId: string; + connectionName: string; + connectionId: string; + suspendStartTime: string; + customSuspendName?: string; + backendSshStatus: BackendSshStatus; + disconnectionTimestamp?: string; +} \ No newline at end of file diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 54e1ffe..68fb8d5 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -4,11 +4,32 @@ import { RequestHandler } from 'express'; import { initializeHeartbeat } from './websocket/heartbeat'; import { initializeUpgradeHandler } from './websocket/upgrade'; import { initializeConnectionHandler } from './websocket/connection'; -import { clientStates } from './websocket/state'; +import { clientStates } from './websocket/state'; +import { sshSuspendService } from './services/ssh-suspend.service'; // 导入实例 +// TemporaryLogStorageService 是 SshSuspendService 的依赖,SshSuspendService 内部会处理它的实例化或导入, +// websocket.ts 层面不需要直接使用 temporaryLogStorageService。 +// 如果 SshSuspendService 的构造函数需要一个 TemporaryLogStorageService 实例, +// 并且 sshSuspendService 实例是由 ssh-suspend.service.ts 文件创建和导出的, +// 那么该文件应该已经处理了 TemporaryLogStorageService 的注入。 +// 因此,我们只需要导入 sshSuspendService。 +import { + SshSuspendClientToServerMessages, + SshSuspendServerToClientMessages, + SuspendedSessionInfo +} from './websocket/types'; import { cleanupClientConnection } from './websocket/utils'; -export { ClientState, AuthenticatedWebSocket, DockerContainer, DockerStats, PortInfo } from './websocket/types'; // Re-export essential types +export { + ClientState, + AuthenticatedWebSocket, + DockerContainer, + DockerStats, + PortInfo, + SshSuspendClientToServerMessages, + SshSuspendServerToClientMessages, + SuspendedSessionInfo +} from './websocket/types'; // Re-export essential types export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise => { // Environment variables are expected to be loaded by index.ts @@ -23,7 +44,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re initializeUpgradeHandler(server, wss, sessionParser); // 3. Initialize Connection Handler (handles 'connection' event and message routing) - initializeConnectionHandler(wss); + initializeConnectionHandler(wss, sshSuspendService); // 传递 sshSuspendService 实例 // --- WebSocket 服务器关闭处理 --- wss.on('close', () => { diff --git a/packages/backend/src/websocket/connection.ts b/packages/backend/src/websocket/connection.ts index 1898e15..c674c16 100644 --- a/packages/backend/src/websocket/connection.ts +++ b/packages/backend/src/websocket/connection.ts @@ -1,6 +1,24 @@ import WebSocket, { WebSocketServer, RawData } from 'ws'; import { Request } from 'express'; -import { AuthenticatedWebSocket } from './types'; +import { + AuthenticatedWebSocket, + SshSuspendStartRequest, + SshSuspendListRequest, + SshSuspendResumeRequest, + SshSuspendTerminateRequest, + SshSuspendRemoveEntryRequest, + SshSuspendEditNameRequest, + SshSuspendStartedResponse, + SshSuspendListResponse, + SshSuspendResumedNotification, + SshOutputCachedChunk, + SshSuspendTerminatedResponse, + SshSuspendEntryRemovedResponse, + SshSuspendNameEditedResponse, + SshSuspendAutoTerminatedNotification, // 尽管此消息由服务发起,但类型定义在此处有用 + ClientState // 导入 ClientState 以便访问 sshClient 等信息 +} from './types'; +import { SshSuspendService } from '../services/ssh-suspend.service'; import { cleanupClientConnection } from './utils'; import { clientStates } from './state'; // Import clientStates for session management @@ -23,7 +41,7 @@ import { handleSftpUploadCancel } from './handlers/sftp.handler'; -export function initializeConnectionHandler(wss: WebSocketServer): void { +export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendService: SshSuspendService): void { wss.on('connection', (ws: AuthenticatedWebSocket, request: Request) => { ws.isAlive = true; const isRdpProxy = (request as any).isRdpProxy; @@ -107,6 +125,200 @@ export function initializeConnectionHandler(wss: WebSocketServer): void { handleSftpUploadCancel(ws, payload); 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}`); // 重复,已包含在上一条日志 + + 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 未定义。`); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_LIST_RESPONSE', payload: { suspendSessions: [] } })); // 返回空列表或错误 + break; + } + try { + const sessions = await sshSuspendService.listSuspendedSessions(ws.userId); + const response: SshSuspendListResponse = { + type: 'SSH_SUSPEND_LIST_RESPONSE', + payload: { suspendSessions: sessions } + }; + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response)); + } catch (error: any) { + console.error(`[SSH_SUSPEND_LIST_REQUEST] 获取挂起列表失败:`, error); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_LIST_RESPONSE', payload: { suspendSessions: [] } })); // 返回空列表或错误 + } + 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}`); + if (!ws.userId) { + console.error(`[SSH_SUSPEND_RESUME_REQUEST] 用户 ID 未定义。Payload: ${JSON.stringify(payload)}`); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_RESUMED_NOTIF', payload: { suspendSessionId, newFrontendSessionId, success: false, error: '用户认证失败' } })); + break; + } + try { + const result = await sshSuspendService.resumeSession(ws.userId, suspendSessionId); + if (result) { + // 将恢复的 sshClient 和 channel 重新关联到新的前端会话 ID + // 这部分逻辑需要与 handleSshConnect 类似,创建一个新的 ClientState + const newSessionState: ClientState = { + ws, // 当前的 WebSocket 连接 + sshClient: result.sshClient, + sshShellStream: result.channel, + dbConnectionId: parseInt(result.originalConnectionId, 10), // 从结果中恢复并转换为数字 + connectionName: result.connectionName, // 从结果中恢复 + ipAddress: clientIp, + isShellReady: true, // 假设恢复后 Shell 立即可用 + }; + clientStates.set(newFrontendSessionId, newSessionState); + ws.sessionId = newFrontendSessionId; // 将当前 ws 与新会话关联 + + // 重新设置事件监听器,将数据流导向新的前端会话 + 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') } })); + } + }); + result.channel.on('close', () => { + 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); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ssh:error', payload: { sessionId: newFrontendSessionId, error: err.message } })); + cleanupClientConnection(newFrontendSessionId); + }); + + // 发送缓存日志块 + // 设计文档建议 SSH_OUTPUT_CACHED_CHUNK + // 这个服务返回的是一个完整的 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)); + + const response: SshSuspendResumedNotification = { + type: 'SSH_SUSPEND_RESUMED', + payload: { suspendSessionId, newFrontendSessionId, success: true } + }; + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response)); + + } else { + throw new Error('无法恢复会话,或会话不存在/状态不正确。'); + } + } catch (error: any) { + console.error(`[SSH_SUSPEND_RESUME_REQUEST] 恢复会话 ${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; + } + case 'SSH_SUSPEND_TERMINATE_REQUEST': { + const { suspendSessionId } = payload as SshSuspendTerminateRequest['payload']; + console.log(`[WebSocket Handler] Received SSH_SUSPEND_TERMINATE_REQUEST. UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, SuspendSessionID: ${suspendSessionId}`); + if (!ws.userId) { + console.error(`[SSH_SUSPEND_TERMINATE_REQUEST] 用户 ID 未定义。Payload: ${JSON.stringify(payload)}`); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_TERMINATED_RESP', payload: { suspendSessionId, success: false, error: '用户认证失败' } })); + break; + } + try { + const success = await sshSuspendService.terminateSuspendedSession(ws.userId, suspendSessionId); + const response: SshSuspendTerminatedResponse = { + type: 'SSH_SUSPEND_TERMINATED', + payload: { suspendSessionId, success } + }; + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response)); + } catch (error: any) { + console.error(`[SSH_SUSPEND_TERMINATE_REQUEST] 终止会话 ${suspendSessionId} 失败:`, error); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_TERMINATED_RESP', payload: { suspendSessionId, success: false, error: error.message || '终止会话失败' } })); + } + break; + } + case 'SSH_SUSPEND_REMOVE_ENTRY': { + const { suspendSessionId } = payload as SshSuspendRemoveEntryRequest['payload']; + console.log(`[WebSocket Handler] Received SSH_SUSPEND_REMOVE_ENTRY. UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, SuspendSessionID: ${suspendSessionId}`); + if (!ws.userId) { + console.error(`[SSH_SUSPEND_REMOVE_ENTRY] 用户 ID 未定义。Payload: ${JSON.stringify(payload)}`); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_ENTRY_REMOVED_RESP', payload: { suspendSessionId, success: false, error: '用户认证失败' } })); + break; + } + try { + const success = await sshSuspendService.removeDisconnectedSessionEntry(ws.userId, suspendSessionId); + const response: SshSuspendEntryRemovedResponse = { + type: 'SSH_SUSPEND_ENTRY_REMOVED', + payload: { suspendSessionId, success } + }; + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response)); + } catch (error: any) { + console.error(`[SSH_SUSPEND_REMOVE_ENTRY] 移除条目 ${suspendSessionId} 失败:`, error); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_ENTRY_REMOVED_RESP', payload: { suspendSessionId, success: false, error: error.message || '移除条目失败' } })); + } + break; + } + case 'SSH_SUSPEND_EDIT_NAME': { + const { suspendSessionId, customName } = payload as SshSuspendEditNameRequest['payload']; + if (!ws.userId) { + console.error(`[SSH_SUSPEND_EDIT_NAME] 用户 ID 未定义。`); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_NAME_EDITED_RESP', payload: { suspendSessionId, success: false, error: '用户认证失败' } })); + break; + } + try { + const success = await sshSuspendService.editSuspendedSessionName(ws.userId, suspendSessionId, customName); + const response: SshSuspendNameEditedResponse = { + type: 'SSH_SUSPEND_NAME_EDITED', + payload: { suspendSessionId, success, customName: success ? customName : undefined } + }; + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response)); + } catch (error: any) { + console.error(`[SSH_SUSPEND_EDIT_NAME] 编辑名称 ${suspendSessionId} 失败:`, error); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_NAME_EDITED_RESP', payload: { suspendSessionId, success: false, error: error.message || '编辑名称失败' } })); + } + break; + } default: console.warn(`WebSocket:收到来自 ${ws.username} (会话: ${sessionId}) 的未知消息类型: ${type}`); if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'error', payload: `不支持的消息类型: ${type}` })); @@ -128,5 +340,27 @@ export function initializeConnectionHandler(wss: WebSocketServer): void { }); } }); - console.log('WebSocket connection handler initialized.'); + + // 监听 SshSuspendService 发出的会话自动终止事件 + sshSuspendService.on('sessionAutoTerminated', (eventPayload: { userId: number; suspendSessionId: string; reason: string }) => { + const { userId, suspendSessionId, reason } = eventPayload; + console.log(`[WebSocket 通知] 准备发送 SSH_SUSPEND_AUTO_TERMINATED_NOTIF 给用户 ${userId} 的会话 ${suspendSessionId}`); + + wss.clients.forEach(client => { + const wsClient = client as AuthenticatedWebSocket; // 类型断言 + if (wsClient.userId === userId && wsClient.readyState === WebSocket.OPEN) { + const notification: SshSuspendAutoTerminatedNotification = { + type: 'SSH_SUSPEND_AUTO_TERMINATED', + payload: { + suspendSessionId, + reason + } + }; + wsClient.send(JSON.stringify(notification)); + console.log(`[WebSocket 通知] 已发送 SSH_SUSPEND_AUTO_TERMINATED_NOTIF 给用户 ${userId} 的一个 WebSocket 连接 (会话 ${suspendSessionId})。`); + } + }); + }); + + console.log('WebSocket connection handler initialized, including SshSuspendService event listener.'); } \ No newline at end of file diff --git a/packages/backend/src/websocket/types.ts b/packages/backend/src/websocket/types.ts index 0f25ba9..59b4d75 100644 --- a/packages/backend/src/websocket/types.ts +++ b/packages/backend/src/websocket/types.ts @@ -21,6 +21,7 @@ export interface ClientState { // 导出以便 Service 可以导入 dockerStatusIntervalId?: NodeJS.Timeout; // NEW: Docker 状态轮询 ID ipAddress?: string; // 添加 IP 地址字段 isShellReady?: boolean; // 新增:标记 Shell 是否已准备好处理输入和调整大小 + isSuspendedByService?: boolean; // 新增:标记此会话是否已被 SshSuspendService 接管 } export interface PortInfo { @@ -56,4 +57,157 @@ export interface DockerContainer { Ports: PortInfo[]; Labels: Record; stats?: DockerStats | null; // 可选的 stats 字段 -} \ No newline at end of file +} +// --- SSH Suspend Mode WebSocket Message Types --- + +// Client -> Server +export interface SshSuspendStartRequest { + type: "SSH_SUSPEND_START"; + payload: { + sessionId: string; // The ID of the active SSH session to be suspended + }; +} + +export interface SshSuspendListRequest { + type: "SSH_SUSPEND_LIST_REQUEST"; +} + +export interface SshSuspendResumeRequest { + type: "SSH_SUSPEND_RESUME_REQUEST"; + payload: { + suspendSessionId: string; // The ID of the suspended session to resume + newFrontendSessionId: string; // The new frontend session ID for the resumed connection + }; +} + +export interface SshSuspendTerminateRequest { + type: "SSH_SUSPEND_TERMINATE_REQUEST"; + payload: { + suspendSessionId: string; // The ID of the active suspended session to terminate + }; +} + +export interface SshSuspendRemoveEntryRequest { + type: "SSH_SUSPEND_REMOVE_ENTRY"; + payload: { + suspendSessionId: string; // The ID of the disconnected session entry to remove + }; +} + +export interface SshSuspendEditNameRequest { + type: "SSH_SUSPEND_EDIT_NAME"; + payload: { + suspendSessionId: string; + customName: string; + }; +} + +// Server -> Client +export interface SshSuspendStartedResponse { + type: "SSH_SUSPEND_STARTED"; + payload: { + frontendSessionId: string; // The original frontend session ID + suspendSessionId: string; // The new ID for the suspended session + success: boolean; + error?: string; + }; +} + +export interface SuspendedSessionInfo { + suspendSessionId: string; + connectionName: string; // Original connection name + connectionId: string; // Original connection ID + suspendStartTime: string; // ISO string + customSuspendName?: string; + backendSshStatus: 'hanging' | 'disconnected_by_backend'; + disconnectionTimestamp?: string; // ISO string, if applicable +} + +export interface SshSuspendListResponse { + type: "SSH_SUSPEND_LIST_RESPONSE"; + payload: { + suspendSessions: SuspendedSessionInfo[]; + }; +} + +export interface SshSuspendResumedNotification { + type: "SSH_SUSPEND_RESUMED"; + payload: { + suspendSessionId: string; + newFrontendSessionId: string; // The frontend session ID this resumed session is now associated with + success: boolean; + error?: string; + }; +} + +export interface SshOutputCachedChunk { + type: "SSH_OUTPUT_CACHED_CHUNK"; + payload: { + frontendSessionId: string; // The frontend session ID to send the chunk to + data: string; + isLastChunk: boolean; + }; +} + +export interface SshSuspendTerminatedResponse { + type: "SSH_SUSPEND_TERMINATED"; + payload: { + suspendSessionId: string; + success: boolean; + error?: string; + }; +} + +export interface SshSuspendEntryRemovedResponse { + type: "SSH_SUSPEND_ENTRY_REMOVED"; + payload: { + suspendSessionId: string; + success: boolean; + error?: string; + }; +} + +export interface SshSuspendNameEditedResponse { + type: "SSH_SUSPEND_NAME_EDITED"; + payload: { + suspendSessionId: string; + success: boolean; + customName?: string; + error?: string; + }; +} + +export interface SshSuspendAutoTerminatedNotification { + type: "SSH_SUSPEND_AUTO_TERMINATED"; + payload: { + suspendSessionId: string; + reason: string; + }; +} + +// Union type for all client-to-server messages for SSH Suspend +export type SshSuspendClientToServerMessages = + | SshSuspendStartRequest + | SshSuspendListRequest + | SshSuspendResumeRequest + | SshSuspendTerminateRequest + | SshSuspendRemoveEntryRequest + | SshSuspendEditNameRequest; + +// Union type for all server-to-client messages for SSH Suspend +export type SshSuspendServerToClientMessages = + | SshSuspendStartedResponse + | SshSuspendListResponse + | SshSuspendResumedNotification + | SshOutputCachedChunk + | SshSuspendTerminatedResponse + | SshSuspendEntryRemovedResponse + | SshSuspendNameEditedResponse + | SshSuspendAutoTerminatedNotification; + +// 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: +// export type WebSocketMessage = BaseMessageType | SshSuspendClientToServerMessages | OtherFeatureMessages; +// And for outgoing: +// export type WebSocketResponse = BaseResponseType | SshSuspendServerToClientMessages | OtherFeatureResponses; +// This part depends on the existing structure, so I'm providing the specific types for now. \ No newline at end of file diff --git a/packages/backend/src/websocket/utils.ts b/packages/backend/src/websocket/utils.ts index b012719..9ff315f 100644 --- a/packages/backend/src/websocket/utils.ts +++ b/packages/backend/src/websocket/utils.ts @@ -80,8 +80,15 @@ export const cleanupClientConnection = (sessionId: string | undefined) => { sftpService.cleanupSftpSession(sessionId); // 3. 清理 SSH 连接 - state.sshShellStream?.end(); // 结束 shell 流 - state.sshClient?.end(); // 结束 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 连接由挂起服务管理,跳过关闭。`); + } + // +++ 结束条件关闭 +++ // 4. 清理 Docker 状态轮询定时器 if (state.dockerStatusIntervalId) { diff --git a/packages/frontend/src/components/LayoutConfigurator.vue b/packages/frontend/src/components/LayoutConfigurator.vue index 077d769..5163491 100644 --- a/packages/frontend/src/components/LayoutConfigurator.vue +++ b/packages/frontend/src/components/LayoutConfigurator.vue @@ -163,6 +163,7 @@ const paneLabels = computed(() => ({ // Assuming labels might depend on i18n commandHistory: t('layout.pane.commandHistory', '命令历史'), quickCommands: t('layout.pane.quickCommands', '快捷指令'), dockerManager: t('layout.pane.dockerManager', 'Docker 管理器'), + suspendedSshSessions: t('layout.pane.suspendedSshSessions'), })); // --- Methods --- diff --git a/packages/frontend/src/components/LayoutRenderer.vue b/packages/frontend/src/components/LayoutRenderer.vue index 75d89b6..711ecbe 100644 --- a/packages/frontend/src/components/LayoutRenderer.vue +++ b/packages/frontend/src/components/LayoutRenderer.vue @@ -79,6 +79,7 @@ const componentMap: Record = { commandHistory: defineAsyncComponent(() => import('../views/CommandHistoryView.vue')), quickCommands: defineAsyncComponent(() => import('../views/QuickCommandsView.vue')), dockerManager: defineAsyncComponent(() => import('./DockerManager.vue')), // <--- 添加 dockerManager 映射 + suspendedSshSessions: defineAsyncComponent(() => import('../views/SuspendedSshSessionsView.vue')), }; // --- Computed --- @@ -111,6 +112,7 @@ const paneLabels = computed(() => ({ commandHistory: t('layout.pane.commandHistory', '命令历史'), quickCommands: t('layout.pane.quickCommands', '快捷指令'), dockerManager: t('layout.pane.dockerManager', 'Docker 管理器'), + suspendedSshSessions: t('layout.panes.suspendedSshSessions', '挂起会话管理'), })); @@ -195,6 +197,10 @@ const componentProps = computed(() => { // onDockerCommand: (payload: { containerId: string; command: 'up' | 'down' | 'restart' | 'stop' }) => emit('dockerCommand', payload), // 暂时不添加事件转发,等组件实现后再确定 }; + case 'suspendedSshSessions': + return { + class: 'flex flex-col flex-grow h-full overflow-auto', // 与 quickCommands 类似 + }; default: return { class: 'pane-content' }; } @@ -345,6 +351,7 @@ const getIconClasses = (paneName: PaneName): string[] => { case 'dockerManager': return ['fab', 'fa-docker']; // Use 'fab' for Docker case 'editor': return ['fas', 'fa-file-alt']; case 'statusMonitor': return ['fas', 'fa-tachometer-alt']; + case 'suspendedSshSessions': return ['fas', 'fa-pause-circle']; // 图标:暂停圈 // Add other specific icons here if needed default: return ['fas', 'fa-question-circle']; // Default icon } diff --git a/packages/frontend/src/components/TerminalTabBar.vue b/packages/frontend/src/components/TerminalTabBar.vue index 9c8bc4f..56805e0 100644 --- a/packages/frontend/src/components/TerminalTabBar.vue +++ b/packages/frontend/src/components/TerminalTabBar.vue @@ -11,7 +11,7 @@ import { useConnectionsStore, type ConnectionInfo } from '../stores/connections. import { useLayoutStore, type PaneName } from '../stores/layout.store'; import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ 新增导入 +++ -import type { SessionTabInfoWithStatus } from '../stores/session.store'; +import type { SessionTabInfoWithStatus } from '../stores/session/types'; // 路径修正 const { t } = useI18n(); // 初始化 i18n @@ -171,6 +171,15 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n // 注意:关闭左侧通常不包括当前标签本身 emitWorkspaceEvent('session:closeToLeft', { targetSessionId: targetId }); break; + case 'suspend-session': // 新增处理 suspend-session 动作 + // 确保 targetId 是字符串类型的 sessionId + if (typeof targetId === 'string') { + console.log(`[TabBar] Context menu action 'suspend-session' requested for session ID: ${targetId}`); + sessionStore.requestStartSshSuspend(targetId); + } else { + console.warn(`[TabBar] 'suspend-session' action called with invalid targetId:`, targetId); + } + break; default: console.warn(`[TabBar] Unknown context menu action: ${action}`); } @@ -180,25 +189,51 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n // 计算右键菜单项 const contextMenuItems = computed(() => { const items = []; - const targetId = contextTargetSessionId.value; - if (!targetId) return []; + const targetSessionIdValue = contextTargetSessionId.value; // 使用局部变量以避免多次访问 .value + if (!targetSessionIdValue) return []; - const currentIndex = props.sessions.findIndex(s => s.sessionId === targetId); + const targetSessionState = sessionStore.sessions.get(targetSessionIdValue); + if (!targetSessionState) return []; // 如果找不到会话状态,则不显示菜单 + + const connectionIdNum = parseInt(targetSessionState.connectionId, 10); + const connectionInfo = connectionsStore.connections.find(c => c.id === connectionIdNum); + + const currentIndex = props.sessions.findIndex(s => s.sessionId === targetSessionIdValue); const totalTabs = props.sessions.length; - items.push({ label: 'tabs.contextMenu.close', action: 'close' }); // 使用 i18n key + // 添加挂起会话菜单项(如果适用) + if (connectionInfo && connectionInfo.type === 'SSH') { + // 仅对活动的SSH会话显示 (可以进一步判断会话是否真的已连接等) + const isActiveSession = targetSessionState.wsManager.isConnected.value; // 检查 WebSocket 是否连接 + if (isActiveSession) { + items.push({ label: 'tabs.contextMenu.suspendSession', action: 'suspend-session' }); + // 为分隔符提供空的 label 和 action 以满足 MenuItem 类型 + items.push({ label: '', action: '', isSeparator: true }); + } + } + + items.push({ label: 'tabs.contextMenu.close', action: 'close' }); if (totalTabs > 1) { items.push({ label: 'tabs.contextMenu.closeOthers', action: 'close-others' }); } - if (currentIndex < totalTabs - 1) { + if (currentIndex < totalTabs - 1 && totalTabs > 1) { // 仅当有右侧标签时显示 items.push({ label: 'tabs.contextMenu.closeRight', action: 'close-right' }); } - if (currentIndex > 0) { + if (currentIndex > 0 && totalTabs > 1) { // 仅当有左侧标签时显示 items.push({ label: 'tabs.contextMenu.closeLeft', action: 'close-left' }); } + + // 移除末尾可能存在的分隔符(如果它是最后一项) + // 确保在 pop 之前检查 items[items.length - 1] 是否真的存在并且是分隔符 + if (items.length > 0) { + const lastItem = items[items.length - 1]; + if (lastItem && lastItem.isSeparator) { + items.pop(); + } + } return items; }); @@ -314,7 +349,7 @@ animation="150" {{ session.connectionName }} diff --git a/packages/frontend/src/composables/useSshTerminal.ts b/packages/frontend/src/composables/useSshTerminal.ts index 7687d9f..c2843eb 100644 --- a/packages/frontend/src/composables/useSshTerminal.ts +++ b/packages/frontend/src/composables/useSshTerminal.ts @@ -1,4 +1,5 @@ import { ref, readonly, type Ref, ComputedRef } from 'vue'; +import { useI18n } from 'vue-i18n'; // +++ Add import for useI18n +++ // import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入 import type { Terminal } from 'xterm'; import type { SearchAddon, ISearchOptions } from '@xterm/addon-search'; // *** 移除 ISearchResult 导入 *** @@ -18,7 +19,7 @@ export interface SshTerminalDependencies { * @param t i18n 翻译函数,从父组件传入 * @returns SSH 终端管理器实例 */ -export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: Function) { +export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: ReturnType['t']) { // +++ Update type of t +++ // 使用依赖注入的 WebSocket 函数 const { sendMessage, onMessage, isConnected } = wsDeps; diff --git a/packages/frontend/src/composables/useWebSocketConnection.ts b/packages/frontend/src/composables/useWebSocketConnection.ts index 72a2d06..bcf3d6a 100644 --- a/packages/frontend/src/composables/useWebSocketConnection.ts +++ b/packages/frontend/src/composables/useWebSocketConnection.ts @@ -1,4 +1,5 @@ import { ref, shallowRef, computed, readonly } from 'vue'; +import { useI18n } from 'vue-i18n'; // +++ Add import for useI18n +++ // 从 websocket.types.ts 导入并重新导出 ConnectionStatus import type { ConnectionStatus as WsConnectionStatusType, MessagePayload, WebSocketMessage, MessageHandler } from '../types/websocket.types'; @@ -14,7 +15,7 @@ export type WsConnectionStatus = WsConnectionStatusType; * @param {Function} t - i18n 翻译函数,从父组件传入 * @returns 一个包含状态和方法的 WebSocket 连接管理器对象。 */ -export function createWebSocketConnectionManager(sessionId: string, dbConnectionId: string, t: Function) { +export function createWebSocketConnectionManager(sessionId: string, dbConnectionId: string, t: ReturnType['t']) { // +++ Update type of t +++ // --- Instance State --- // 每个实例拥有独立的 WebSocket 对象、状态和消息处理器 const ws = shallowRef(null); // WebSocket 实例 diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 7456602..96cc4ce 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -26,7 +26,12 @@ if (availableLocales.length === 0) { // 类型推断 (基于第一个加载的语言文件,假设所有文件结构一致) // 如果没有加载到文件,则使用空对象作为 fallback,避免运行时错误 // 使用更通用的类型 Record 来避免动态索引的类型推断问题 -type MessageSchema = Record; +// 尝试一个更具体的类型来帮助 TypeScript 推断,以解决深层实例化问题 +// 这允许嵌套的翻译键,例如 'parent.child.grandchild' +interface RecursiveStringRecord { + [key: string]: string | RecursiveStringRecord; +} +type MessageSchema = RecursiveStringRecord; // 定义默认语言 (优先使用 'en-US',如果不存在则使用第一个找到的语言) export const defaultLng = availableLocales.includes('en-US') ? 'en-US' : availableLocales[0] || 'en-US'; // 更新为 en-US diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 624c8ae..6a33494 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -975,7 +975,8 @@ "statusMonitor": "Status Monitor", "commandHistory": "Command History", "quickCommands": "Quick Commands", - "dockerManager": "Docker Manager" + "dockerManager": "Docker Manager", + "suspendedSshSessions": "Suspended Sessions Management" }, "noActiveSession": { "title": "No Active Session", @@ -985,7 +986,8 @@ } }, "header": { - "hide": "Hide" + "hide": "Hide", + "show": "Show Top Navigation" }, "commandHistory": { "searchPlaceholder": "Search history...", @@ -1134,8 +1136,11 @@ "close": "Close Tab", "closeOthers": "Close Other Tabs", "closeRight": "Close Tabs to the Right", - "closeLeft": "Close Tabs to the Left" - } + "closeLeft": "Close Tabs to the Left", + "suspendSession": "Suspend Session" + }, + "closeTabTooltip": "Close Tab", + "newTabTooltip": "New Connection Tab" }, "sshKeys": { "selector": { @@ -1166,5 +1171,30 @@ "keyUpdateNote": "Leave private key blank to keep the existing key. Passphrase always needs re-entry if required.", "passphraseUpdateNote": "Leave blank to keep or remove the passphrase. Enter a new passphrase to update." } + }, + "suspendedSshSessions": { + "searchPlaceholder": "Search sessions (name, connection...)", + "loading": "Loading suspended sessions...", + "noResults": "No suspended sessions found matching your criteria.", + "tooltip": { + "editName": "Click to edit name" + }, + "label": { + "originalConnection": "Original Connection", + "suspendedAt": "Suspended At" + }, + "disconnectedAt": "Disconnected at {time}", + "status": { + "hanging": "Active", + "disconnected": "Disconnected" + }, + "action": { + "resume": "Resume", + "remove": "Remove" + } + }, + "time": { + "unknown": "Unknown time", + "invalidDate": "Invalid date" } } diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 80d5e4a..86af26d 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -977,7 +977,8 @@ "statusMonitor": "状态监视器", "commandHistory": "命令历史", "quickCommands": "快捷指令", - "dockerManager": "Docker 管理器" + "dockerManager": "Docker 管理器", + "suspendedSshSessions": "挂起会话管理" }, "noActiveSession": { "title": "无活动会话", @@ -987,7 +988,8 @@ } }, "header": { - "hide": "隐藏" + "hide": "隐藏", + "show": "显示顶部导航" }, "commandHistory": { "searchPlaceholder": "搜索历史记录...", @@ -1136,8 +1138,11 @@ "close": "关闭标签页", "closeOthers": "关闭其他标签页", "closeRight": "关闭右侧标签页", - "closeLeft": "关闭左侧标签页" - } + "closeLeft": "关闭左侧标签页", + "suspendSession": "挂起会话" + }, + "closeTabTooltip": "关闭标签页", + "newTabTooltip": "新建连接标签页" }, "sshKeys": { "selector": { @@ -1168,5 +1173,30 @@ "keyUpdateNote": "将私钥留空以保留现有密钥。密码短语始终需要重新输入(如果需要)。", "passphraseUpdateNote": "留空表示不修改或移除密码短语。输入新密码短语以更新。" } + }, + "suspendedSshSessions": { + "searchPlaceholder": "搜索会话 (名称, 连接名...)", + "loading": "正在加载挂起的会话...", + "noResults": "没有找到符合条件的挂起会话。", + "tooltip": { + "editName": "点击编辑名称" + }, + "label": { + "originalConnection": "原始连接", + "suspendedAt": "挂起于" + }, + "disconnectedAt": "已于 {time} 断开", + "status": { + "hanging": "活跃", + "disconnected": "已断开" + }, + "action": { + "resume": "恢复", + "remove": "移除" + } + }, + "time": { + "unknown": "未知时间", + "invalidDate": "无效日期" } } diff --git a/packages/frontend/src/stores/layout.store.ts b/packages/frontend/src/stores/layout.store.ts index 2199553..dace426 100644 --- a/packages/frontend/src/stores/layout.store.ts +++ b/packages/frontend/src/stores/layout.store.ts @@ -3,7 +3,7 @@ import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'; import apiClient from '../utils/apiClient'; // 定义所有可用面板的名称 -export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager'; +export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager' | 'suspendedSshSessions'; // 定义布局节点接口 export interface LayoutNode { @@ -166,7 +166,7 @@ export const useLayoutStore = defineStore('layout', () => { const allPossiblePanes: Ref = ref([ 'connections', 'terminal', 'commandBar', 'fileManager', 'editor', 'statusMonitor', 'commandHistory', 'quickCommands', - 'dockerManager' // <--- 在这里添加 'dockerManager' + 'dockerManager', 'suspendedSshSessions' // <-- 添加新的挂起 SSH 会话视图 ]); // 新增:控制布局(Header/Footer)可见性的状态 const isLayoutVisible: Ref = ref(true); // 控制整体布局(Header/Footer)可见性 diff --git a/packages/frontend/src/stores/session.store.ts b/packages/frontend/src/stores/session.store.ts index 25a6b8b..6cb716c 100644 --- a/packages/frontend/src/stores/session.store.ts +++ b/packages/frontend/src/stores/session.store.ts @@ -13,6 +13,9 @@ import { rdpConnectionInfo, isVncModalOpen, vncConnectionInfo, + // SSH Suspend Mode State + suspendedSshSessions, + isLoadingSuspendedSessions, } from './session/state'; // 从新模块导入 Getters @@ -28,6 +31,7 @@ import * as editorActions from './session/actions/editorActions'; import * as sftpManagerActions from './session/actions/sftpManagerActions'; import * as modalActions from './session/actions/modalActions'; import * as commandInputActions from './session/actions/commandInputActions'; +import * as sshSuspendActions from './session/actions/sshSuspendActions'; // 新增:导入 SSH 挂起 Actions // 导入需要的类型 (例如 FileInfo 可能会在参数中使用) import type { FileInfo } from './fileEditor.store'; @@ -51,7 +55,7 @@ export const useSessionStore = defineStore('session', () => { // Session Actions const openNewSession = (connectionId: number | string) => - sessionActions.openNewSession(connectionId, { connectionsStore, t }); + sessionActions.openNewSession(connectionId, { connectionsStore, t }); // 移除了 router 和不正确的 registerSshSuspendHandlers const activateSession = (sessionId: string) => sessionActions.activateSession(sessionId); const closeSession = (sessionId: string) => sessionActions.closeSession(sessionId); const handleConnectRequest = (connection: ConnectionInfo) => @@ -63,7 +67,7 @@ export const useSessionStore = defineStore('session', () => { t, }); const handleOpenNewSession = (connectionId: number | string) => - sessionActions.handleOpenNewSession(connectionId, { connectionsStore, t }); + sessionActions.handleOpenNewSession(connectionId, { connectionsStore, t }); // 移除了 router 和不正确的 registerSshSuspendHandlers const cleanupAllSessions = () => sessionActions.cleanupAllSessions(); // SFTP Manager Actions @@ -105,6 +109,9 @@ export const useSessionStore = defineStore('session', () => { rdpConnectionInfo, isVncModalOpen, vncConnectionInfo, + // SSH Suspend Mode State + suspendedSshSessions, + isLoadingSuspendedSessions, // Getters (直接从 getters 模块导出) sessionTabs, @@ -134,5 +141,8 @@ export const useSessionStore = defineStore('session', () => { openVncModal, closeVncModal, updateSessionCommandInput, + + // SSH Suspend Actions (直接从模块导出,Pinia 会处理) + ...sshSuspendActions, }; }); diff --git a/packages/frontend/src/stores/session/actions/sessionActions.ts b/packages/frontend/src/stores/session/actions/sessionActions.ts index 0f43b2b..fe1fa35 100644 --- a/packages/frontend/src/stores/session/actions/sessionActions.ts +++ b/packages/frontend/src/stores/session/actions/sessionActions.ts @@ -6,13 +6,14 @@ import { useI18n } from 'vue-i18n'; import { useConnectionsStore, type ConnectionInfo } from '../../connections.store'; // 路径: packages/frontend/src/stores/connections.store.ts import { sessions, activeSessionId } from '../state'; import { generateSessionId } from '../utils'; -import type { SessionState, SshTerminalInstance, StatusMonitorInstance, DockerManagerInstance, SftpManagerInstance } from '../types'; +import type { SessionState, SshTerminalInstance, StatusMonitorInstance, DockerManagerInstance, SftpManagerInstance, WsManagerInstance } from '../types'; // Composables for manager creation - 路径相对于此文件 import { createWebSocketConnectionManager } from '../../../composables/useWebSocketConnection'; import { createSshTerminalManager, type SshTerminalDependencies } from '../../../composables/useSshTerminal'; import { createStatusMonitorManager, type StatusMonitorDependencies } from '../../../composables/useStatusMonitor'; import { createDockerManager, type DockerManagerDependencies } from '../../../composables/useDockerManager'; +import { registerSshSuspendHandlers } from './sshSuspendActions'; // 新增:导入 SSH 挂起处理器注册函数 // getOrCreateSftpManager 将在 sftpManagerActions.ts 中定义,并在主 store 中协调 // --- 辅助函数 (特定于此模块的 actions) --- @@ -26,10 +27,11 @@ export const openNewSession = ( dependencies: { connectionsStore: ReturnType; t: ReturnType['t']; - } + }, + existingSessionId?: string // 新增:可选的预定义会话 ID ) => { const { connectionsStore, t } = dependencies; - console.log(`[SessionActions] 请求打开新会话: ${connectionId}`); + console.log(`[SessionActions] 请求打开新会话: ${connectionId}${existingSessionId ? `, 使用预定义 ID: ${existingSessionId}` : ''}`); const connInfo = findConnectionInfo(connectionId, connectionsStore); if (!connInfo) { console.error(`[SessionActions] 无法打开新会话:找不到 ID 为 ${connectionId} 的连接信息。`); @@ -37,7 +39,7 @@ export const openNewSession = ( return; } - const newSessionId = generateSessionId(); + const newSessionId = existingSessionId || generateSessionId(); const dbConnId = String(connInfo.id); // 1. 创建管理器实例 @@ -73,6 +75,7 @@ export const openNewSession = ( editorTabs: ref([]), activeEditorTabId: ref(null), commandInputContent: ref(''), + disposables: [], // 初始化 disposables 数组 }; // 3. 添加到 Map 并激活 @@ -82,6 +85,57 @@ export const openNewSession = ( activeSessionId.value = newSessionId; console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId}`); + // +++ 在连接前设置 ssh:connected 处理器以更新 sessionId +++ + const originalFrontendSessionIdForHandler = newSessionId; // 捕获初始ID给闭包 + + const unregisterConnectedHandler = wsManager.onMessage('ssh:connected', (connectedPayload: any) => { + const backendSID = connectedPayload.sessionId as string; + const backendCID = String(connectedPayload.connectionId); + + console.log(`[SessionActions/ssh:connected] 收到消息。前端初始SID: ${originalFrontendSessionIdForHandler}, 后端SID: ${backendSID}, 后端CID: ${backendCID}`); + + const sessionToUpdate = sessions.value.get(originalFrontendSessionIdForHandler); + + if (sessionToUpdate) { + if (sessionToUpdate.connectionId !== backendCID) { + console.warn(`[SessionActions/ssh:connected] 后端CID ${backendCID} 与会话 ${originalFrontendSessionIdForHandler} 的期望CID ${sessionToUpdate.connectionId} 不匹配。中止SID更新。`); + return; + } + + if (backendSID && backendSID !== originalFrontendSessionIdForHandler) { + console.log(`[SessionActions/ssh:connected] 会话ID需要更新:从 ${originalFrontendSessionIdForHandler} 到 ${backendSID}。`); + const currentSessions = new Map(sessions.value); + currentSessions.delete(originalFrontendSessionIdForHandler); + + sessionToUpdate.sessionId = backendSID; // 更新会话对象内部的sessionId + + currentSessions.set(backendSID, sessionToUpdate); + sessions.value = currentSessions; + + if (activeSessionId.value === originalFrontendSessionIdForHandler) { + activeSessionId.value = backendSID; + console.log(`[SessionActions/ssh:connected] 活动会话ID已更新为 ${backendSID}。`); + } + console.log(`[SessionActions/ssh:connected] 会话存储已更新,新键为 ${backendSID}。`); + } else if (backendSID === originalFrontendSessionIdForHandler) { + console.log(`[SessionActions/ssh:connected] 后端SID ${backendSID} 与前端SID匹配。无需重新键控。`); + } else { + console.error(`[SessionActions/ssh:connected] 从后端收到的 ssh:connected 消息中缺少有效的sessionId。Payload:`, connectedPayload); + } + } else { + console.warn(`[SessionActions/ssh:connected] 当处理后端SID ${backendSID} 时,在存储中未找到对应的前端初始SID ${originalFrontendSessionIdForHandler} 的会话。`); + } + // 此处理器主要用于初始的 sessionId 同步,通常在第一次收到 ssh:connected 后就可以注销, + // 以避免后续可能的意外重连消息再次触发此逻辑。 + // 但如果 backendID 保证在 ssh:connected 时才首次确定,则保留可能也无害。 + // 为简单起见,暂不在此处自动注销。注销将在 closeSession 中处理。 + }); + + if (newSession.disposables) { + newSession.disposables.push(unregisterConnectedHandler); + } + + // 4. 启动 WebSocket 连接 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHostAndPort = window.location.host; @@ -89,6 +143,16 @@ export const openNewSession = ( console.log(`[SessionActions] Generated WebSocket URL: ${wsUrl}`); wsManager.connect(wsUrl); console.log(`[SessionActions] 已为会话 ${newSessionId} 启动 WebSocket 连接。`); + + // 注册 SSH 挂起相关的 WebSocket 消息处理器 + // 确保只对 SSH 类型的连接注册 (虽然 wsManager 本身不包含类型信息,但 openNewSession 通常只为 SSH 调用) + // 如果 connInfo 存在且类型为 SSH,则注册 + if (connInfo && connInfo.type === 'SSH') { + registerSshSuspendHandlers(wsManager); + console.log(`[SessionActions] 已为 SSH 会话 ${newSessionId} 注册 SSH 挂起处理器。`); + } else if (connInfo) { + console.log(`[SessionActions] 会话 ${newSessionId} 类型为 ${connInfo.type},不注册 SSH 挂起处理器。`); + } }; export const activateSession = (sessionId: string) => { @@ -121,6 +185,18 @@ export const closeSession = (sessionId: string) => { }); sessionToClose.sftpManagers.clear(); sessionToClose.terminalManager.cleanup(); + // 调用存储在会话中的所有清理函数 + if (sessionToClose.disposables && Array.isArray(sessionToClose.disposables)) { + sessionToClose.disposables.forEach(dispose => { + try { + dispose(); + } catch (e) { + console.error(`[SessionActions] 清理disposable时出错:`, e); + } + }); + sessionToClose.disposables = []; // 清空数组 + console.log(`[SessionActions] 已为会话 ${sessionId} 调用所有disposables。`); + } console.log(`[SessionActions] 已为会话 ${sessionId} 调用 terminalManager.cleanup()`); sessionToClose.statusMonitorManager.cleanup(); console.log(`[SessionActions] 已为会话 ${sessionId} 调用 statusMonitorManager.cleanup()`); @@ -197,7 +273,7 @@ export const handleOpenNewSession = ( } ) => { console.log(`[SessionActions] handleOpenNewSession called for ID: ${connectionId}`); - openNewSession(connectionId, dependencies); + openNewSession(connectionId, dependencies); // existingSessionId 将为 undefined,因此会生成新的 }; export const cleanupAllSessions = () => { diff --git a/packages/frontend/src/stores/session/actions/sshSuspendActions.ts b/packages/frontend/src/stores/session/actions/sshSuspendActions.ts new file mode 100644 index 0000000..0847b46 --- /dev/null +++ b/packages/frontend/src/stores/session/actions/sshSuspendActions.ts @@ -0,0 +1,450 @@ +// packages/frontend/src/stores/session/actions/sshSuspendActions.ts +import { v4 as uuidv4 } from 'uuid'; +import { sessions, suspendedSshSessions, isLoadingSuspendedSessions, activeSessionId } from '../state'; +import type { + MessagePayload, // 新增导入 + SshSuspendStartReqMessage, + // SshSuspendListReqMessage, // 不再需要,因为 fetch 将通过 HTTP + SshSuspendResumeReqMessage, + SshSuspendTerminateReqMessage, + SshSuspendRemoveEntryReqMessage, + SshSuspendEditNameReqMessage, + // S2C Payloads + SshSuspendStartedRespPayload, + SshSuspendListResponsePayload, // 仍然需要处理来自 WS 的列表更新推送(如果后端支持) + SshSuspendResumedNotifPayload, + SshOutputCachedChunkPayload, + SshSuspendTerminatedRespPayload, + SshSuspendEntryRemovedRespPayload, + SshSuspendNameEditedRespPayload, + 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 { 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 +import i18n from '../../../i18n'; // 直接导入 i18n 实例 +import type { ComposerTranslation } from 'vue-i18n'; // 导入 ComposerTranslation 类型 +import apiClient from '../../../utils/apiClient'; // +++ 新增:导入 apiClient +++ + +const t: ComposerTranslation = i18n.global.t; // 从全局 i18n 实例获取 t 函数并显式注解类型 + +// 辅助函数:获取一个可用的 WebSocket 管理器 +// 优先使用当前激活的会话,或者任意一个已连接的 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}`); + }); + + const firstSessionKey = sessions.value.size > 0 ? sessions.value.keys().next().value : null; + 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}`); + if (session && session.wsManager && session.wsManager.isConnected.value) { + console.log(`[getActiveWsManager] 使用第一个会话 (ID: ${firstSessionKey}) 的 WebSocket。`); + return session.wsManager; + } + } + + 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}`); + if (session.wsManager && session.wsManager.isConnected.value) { + console.log(`[getActiveWsManager] 遍历成功,使用会话 (ID: ${sessionId}) 的 WebSocket。`); + return session.wsManager; + } + } + + console.warn('[getActiveWsManager] 遍历结束,仍未找到可用的 WebSocket 连接来发送 SSH 挂起相关请求。'); + return null; +}; + + +/** + * 请求启动 SSH 会话挂起 + * @param sessionId 要挂起的活动会话 ID + */ +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})。`); + // 可选:通知用户 + return; + } + const message: SshSuspendStartReqMessage = { + type: 'SSH_SUSPEND_START', + payload: { sessionId }, + }; + session.wsManager.sendMessage(message); + console.log(`[${t('term.sshSuspend')}] 已发送 SSH_SUSPEND_START_REQ (会话 ID: ${sessionId})`); + } else { + console.warn(`[${t('term.sshSuspend')}] 未找到会话或 WebSocket 管理器 (会话 ID: ${sessionId}),无法启动挂起。`); + } +}; + +/** + * 获取挂起的 SSH 会话列表 (通过 HTTP API) + */ +export const fetchSuspendedSshSessions = async (): Promise => { + isLoadingSuspendedSessions.value = true; + try { + // 假设后端 API 端点为 /api/ssh/suspended-sessions + // 并且它返回 SuspendedSshSession[] 类型的数据 + const response = await apiClient.get('ssh-suspend/suspended-sessions'); + suspendedSshSessions.value = response.data; + console.log(`[${t('term.sshSuspend')}] 已通过 HTTP 获取挂起列表,数量: ${response.data.length}`); + } catch (error) { + console.error(`[${t('term.sshSuspend')}] 通过 HTTP 获取挂起列表失败:`, error); + // 可选:通知用户错误 + const uiNotificationsStore = useUiNotificationsStore(); + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.fetchListError', { error: String(error) }), + }); + // 即使失败,也可能需要清空旧数据或保留旧数据,具体取决于产品需求 + // suspendedSshSessions.value = []; // 例如,失败时清空 + } finally { + isLoadingSuspendedSessions.value = false; + } +}; + +/** + * 请求恢复指定的挂起 SSH 会话 + * @param suspendSessionId 要恢复的挂起会话的 ID + */ +export const resumeSshSession = (suspendSessionId: string): void => { + const wsManager = getActiveWsManager(); + if (wsManager) { + const newFrontendSessionId = uuidv4(); // 为恢复的会话生成新的前端 ID + const message: SshSuspendResumeReqMessage = { + type: 'SSH_SUSPEND_RESUME_REQUEST', + payload: { suspendSessionId, newFrontendSessionId }, + }; + 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 连接。`); + } +}; + +/** + * 请求终止并移除一个活跃的挂起 SSH 会话 + * @param suspendSessionId 要终止并移除的挂起会话 ID + */ +export const terminateAndRemoveSshSession = async (suspendSessionId: string): Promise => { + console.log(`[${t('term.sshSuspend')}] 请求通过 HTTP API 终止并移除挂起会话 (ID: ${suspendSessionId})`); + const uiNotificationsStore = useUiNotificationsStore(); + try { + // 假设后端 API 返回成功时状态码为 200/204,失败时返回错误信息 + await apiClient.delete(`ssh-suspend/terminate/${suspendSessionId}`); + console.log(`[${t('term.sshSuspend')}] HTTP API 终止并移除会话 ${suspendSessionId} 成功。`); + + // 复用或直接实现 handleSshSuspendTerminatedResp 的逻辑 + const index = suspendedSshSessions.value.findIndex(s => s.suspendSessionId === suspendSessionId); + if (index !== -1) { + const removedSession = suspendedSshSessions.value.splice(index, 1)[0]; + uiNotificationsStore.addNotification({ + type: 'info', + message: t('sshSuspend.notifications.terminatedSuccess', { name: removedSession.customSuspendName || removedSession.connectionName }), + }); + } + } catch (error: any) { + console.error(`[${t('term.sshSuspend')}] 通过 HTTP API 终止并移除会话 ${suspendSessionId} 失败:`, error); + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.terminateError', { error: error.response?.data?.message || error.message || t('term.unknownError') }), + }); + } +}; + +/** + * 请求移除一个已断开的挂起 SSH 会话条目 + * @param suspendSessionId 要移除的挂起会话条目 ID + */ +export const removeSshSessionEntry = async (suspendSessionId: string): Promise => { + console.log(`[${t('term.sshSuspend')}] 请求通过 HTTP API 移除已断开的挂起条目 (ID: ${suspendSessionId})`); + const uiNotificationsStore = useUiNotificationsStore(); + try { + await apiClient.delete(`ssh-suspend/entry/${suspendSessionId}`); + console.log(`[${t('term.sshSuspend')}] HTTP API 移除已断开条目 ${suspendSessionId} 成功。`); + + // 复用或直接实现 handleSshSuspendEntryRemovedResp 的逻辑 + const index = suspendedSshSessions.value.findIndex(s => s.suspendSessionId === suspendSessionId); + if (index !== -1) { + const removedSession = suspendedSshSessions.value.splice(index, 1)[0]; + uiNotificationsStore.addNotification({ + type: 'info', + message: t('sshSuspend.notifications.entryRemovedSuccess', { name: removedSession.customSuspendName || removedSession.connectionName }), + }); + } + } catch (error: any) { + console.error(`[${t('term.sshSuspend')}] 通过 HTTP API 移除已断开条目 ${suspendSessionId} 失败:`, error); + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.entryRemovedError', { error: error.response?.data?.message || error.message || t('term.unknownError') }), + }); + } +}; + +/** + * 请求编辑挂起 SSH 会话的自定义名称 + * @param suspendSessionId 要编辑的挂起会话 ID + * @param customName 新的自定义名称 + */ +export const editSshSessionName = (suspendSessionId: string, customName: string): void => { + const wsManager = getActiveWsManager(); + if (wsManager) { + const message: SshSuspendEditNameReqMessage = { + type: 'SSH_SUSPEND_EDIT_NAME', + payload: { suspendSessionId, customName }, + }; + wsManager.sendMessage(message); + console.log(`[${t('term.sshSuspend')}] 已发送 SSH_SUSPEND_EDIT_NAME_REQ (挂起 ID: ${suspendSessionId}, 名称: "${customName}")`); + } else { + console.warn(`[${t('term.sshSuspend')}] 编辑挂起名称失败 (挂起 ID: ${suspendSessionId}):无可用 WebSocket 连接。`); + } +}; + +// --- S2C Message Handlers --- + +const handleSshSuspendStartedResp = (payload: SshSuspendStartedRespPayload): void => { + const uiNotificationsStore = useUiNotificationsStore(); + console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_STARTED_RESP:`, payload); + if (payload.success) { + uiNotificationsStore.addNotification({ + type: 'success', + message: t('sshSuspend.notifications.suspendStartedSuccess', { id: payload.suspendSessionId.slice(0, 8) }), + }); + // 成功后关闭原会话标签页 + closeSessionAction(payload.frontendSessionId); + // 刷新挂起列表 (可选,或者等待列表更新通知) + fetchSuspendedSshSessions(); + } else { + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.suspendStartedError', { error: payload.error || t('term.unknownError') }), + }); + console.error(`[${t('term.sshSuspend')}] 挂起失败 (前端会话 ID: ${payload.frontendSessionId}): ${payload.error}`); + } +}; + +const handleSshSuspendListResponse = (payload: SshSuspendListResponsePayload): void => { + console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_LIST_RESPONSE,数量: ${payload.suspendSessions.length}`); + suspendedSshSessions.value = payload.suspendSessions; + isLoadingSuspendedSessions.value = false; +}; + +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); + 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; + } + + try { + // 使用 openNewSession 创建会话 + openNewSession( + connectionInfo.id, // connectionId + { connectionsStore, t }, // dependencies + payload.newFrontendSessionId // existingSessionId + ); + + // 获取新创建的会话 + 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); + uiNotificationsStore.addNotification({ + type: 'success', + message: t('sshSuspend.notifications.resumeSuccess', { name: suspendedSession.customSuspendName || suspendedSession.connectionName }), + }); + // 后端会开始发送 SSH_OUTPUT_CACHED_CHUNK + } else { + throw new Error('通过 openNewSession 创建或获取新会话实例失败,或 WebSocket 管理器未初始化。'); + } + } catch (error) { + console.error(`[${t('term.sshSuspend')}] 处理会话恢复通知时出错:`, error); + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.resumeErrorGeneric', { error: String(error) }), + }); + } + // 成功恢复后,从挂起列表中移除 (或者等 SSH_SUSPEND_ENTRY_REMOVED_RESP) + // 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}`); + } +}; + +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 (payload.isLastChunk) { + console.log(`[${t('term.sshSuspend')}] (会话: ${payload.frontendSessionId}) 已接收所有缓存输出。`); + // 可选:在这里触发一个事件或状态,表明缓存输出已加载完毕 + // 例如,如果之前终端是只读/加载状态,现在可以解除 + if (session.isResuming === true) { + session.isResuming = false; + // 可能需要重新聚焦终端或进行其他 UI 更新 + } + } + } else { + console.warn(`[${t('term.sshSuspend')}] 收到缓存数据块,但找不到对应会话、终端管理器或终端实例 (ID: ${payload.frontendSessionId})`); + } +}; + +const handleSshSuspendTerminatedResp = (payload: SshSuspendTerminatedRespPayload): void => { + const uiNotificationsStore = useUiNotificationsStore(); + console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_TERMINATED_RESP:`, payload); + if (payload.success) { + const index = suspendedSshSessions.value.findIndex(s => s.suspendSessionId === payload.suspendSessionId); + if (index !== -1) { + const removedSession = suspendedSshSessions.value.splice(index, 1)[0]; + uiNotificationsStore.addNotification({ + type: 'info', + message: t('sshSuspend.notifications.terminatedSuccess', { name: removedSession.customSuspendName || removedSession.connectionName }), + }); + } + } else { + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.terminateError', { error: payload.error || t('term.unknownError') }), + }); + console.error(`[${t('term.sshSuspend')}] 终止挂起会话失败 (ID: ${payload.suspendSessionId}): ${payload.error}`); + } +}; + +const handleSshSuspendEntryRemovedResp = (payload: SshSuspendEntryRemovedRespPayload): void => { + const uiNotificationsStore = useUiNotificationsStore(); + console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_ENTRY_REMOVED_RESP:`, payload); + if (payload.success) { + const index = suspendedSshSessions.value.findIndex(s => s.suspendSessionId === payload.suspendSessionId); + if (index !== -1) { + const removedSession = suspendedSshSessions.value.splice(index, 1)[0]; + uiNotificationsStore.addNotification({ + type: 'info', + message: t('sshSuspend.notifications.entryRemovedSuccess', { name: removedSession.customSuspendName || removedSession.connectionName }), + }); + } + } else { + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.entryRemovedError', { error: payload.error || t('term.unknownError') }), + }); + console.error(`[${t('term.sshSuspend')}] 移除挂起条目失败 (ID: ${payload.suspendSessionId}): ${payload.error}`); + } +}; + +const handleSshSuspendNameEditedResp = (payload: SshSuspendNameEditedRespPayload): void => { + const uiNotificationsStore = useUiNotificationsStore(); + console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_NAME_EDITED_RESP:`, payload); + if (payload.success && payload.customName !== undefined) { + const session = suspendedSshSessions.value.find(s => s.suspendSessionId === payload.suspendSessionId); + if (session) { + session.customSuspendName = payload.customName; + uiNotificationsStore.addNotification({ + type: 'success', + message: t('sshSuspend.notifications.nameEditedSuccess', { name: payload.customName }), + }); + } + } else { + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.nameEditedError', { error: payload.error || t('term.unknownError') }), + }); + console.error(`[${t('term.sshSuspend')}] 编辑挂起名称失败 (ID: ${payload.suspendSessionId}): ${payload.error}`); + } +}; + +const handleSshSuspendAutoTerminatedNotif = (payload: SshSuspendAutoTerminatedNotifPayload): void => { + const uiNotificationsStore = useUiNotificationsStore(); + console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_AUTO_TERMINATED_NOTIF:`, payload); + const session = suspendedSshSessions.value.find(s => s.suspendSessionId === payload.suspendSessionId); + if (session) { + session.backendSshStatus = 'disconnected_by_backend'; // 使用正确的字段名 + session.disconnectionTimestamp = new Date().toISOString(); // 更新为 ISO 字符串 + // 可以在 SuspendedSshSession 类型中添加 disconnectionReason 字段 + // session.disconnectionReason = payload.reason; + uiNotificationsStore.addNotification({ + type: 'warning', + message: t('sshSuspend.notifications.autoTerminated', { name: session.customSuspendName || session.connectionName, reason: payload.reason }), + }); + } +}; + +/** + * 注册 SSH 挂起相关的 WebSocket 消息处理器。 + * 此函数应在 WebSocket 连接建立后,针对每个会话的 wsManager 实例调用。 + * @param wsManager 与特定 SSH 会话关联的 WebSocket 管理器实例 + */ +export const registerSshSuspendHandlers = (wsManager: WsManagerInstance): void => { + console.log(`[${t('term.sshSuspend')}] 尝试为 WebSocket 管理器注册 SSH 挂起处理器...`); + + if (!wsManager) { + console.error(`[${t('term.sshSuspend')}] 注册处理器失败:wsManager 未定义。`); + return; + } + + // 注意:wsManager.onMessage 返回一个注销函数,如果需要,可以收集它们并在会话关闭时调用。 + // 但通常这些处理器会随 wsManager 实例的生命周期一起存在。 + wsManager.onMessage('SSH_SUSPEND_STARTED_RESP', (p: MessagePayload) => handleSshSuspendStartedResp(p as SshSuspendStartedRespPayload)); + 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)); + wsManager.onMessage('SSH_SUSPEND_TERMINATED_RESP', (p: MessagePayload) => handleSshSuspendTerminatedResp(p as SshSuspendTerminatedRespPayload)); + wsManager.onMessage('SSH_SUSPEND_ENTRY_REMOVED_RESP', (p: MessagePayload) => handleSshSuspendEntryRemovedResp(p as SshSuspendEntryRemovedRespPayload)); + wsManager.onMessage('SSH_SUSPEND_NAME_EDITED_RESP', (p: MessagePayload) => handleSshSuspendNameEditedResp(p as SshSuspendNameEditedRespPayload)); + wsManager.onMessage('SSH_SUSPEND_AUTO_TERMINATED_NOTIF', (p: MessagePayload) => handleSshSuspendAutoTerminatedNotif(p as SshSuspendAutoTerminatedNotifPayload)); + + console.log(`[${t('term.sshSuspend')}] SSH 挂起模式的 WebSocket 消息处理器已注册。`); + + // 连接建立后,主动获取一次挂起列表 + // 考虑:是否应该在这里做,或者在应用启动时做一次? + // 如果 wsManager 是针对某个具体会话的,那么每个会话连接时都获取列表可能不是最优。 + // 更好的地方可能是在 App.vue 或主会话 store 初始化时,通过一个“全局”的 wsManager (如果存在) 或其中一个 wsManager 获取。 + // 但如果挂起列表只通过当前连接的 ws 通道获取,那这里是合适的。 + // 假设 getActiveWsManager 能取到这个 wsManager 实例,那 actions.ts 里的 fetchSuspendedSshSessions() 会用它 + // 这里直接调用 fetchSuspendedSshSessions() 也可以 + fetchSuspendedSshSessions(); +}; \ No newline at end of file diff --git a/packages/frontend/src/stores/session/state.ts b/packages/frontend/src/stores/session/state.ts index 04f9610..edb8094 100644 --- a/packages/frontend/src/stores/session/state.ts +++ b/packages/frontend/src/stores/session/state.ts @@ -4,6 +4,7 @@ import { ref, shallowRef } from 'vue'; import type { SessionState } from './types'; // 修正导入路径 import type { ConnectionInfo } from '../connections.store'; // 路径: packages/frontend/src/stores/connections.store.ts +import type { SuspendedSshSession } from '../../types/ssh-suspend.types'; // 路径: packages/frontend/src/types/ssh-suspend.types.ts // 使用 shallowRef 避免深度响应性问题,保留管理器实例内部的响应性 export const sessions = shallowRef>(new Map()); @@ -15,4 +16,8 @@ export const rdpConnectionInfo = ref(null); // --- VNC Modal State --- export const isVncModalOpen = ref(false); -export const vncConnectionInfo = ref(null); \ No newline at end of file +export const vncConnectionInfo = ref(null); + +// --- SSH Suspend Mode State --- +export const suspendedSshSessions = ref([]); +export const isLoadingSuspendedSessions = ref(false); \ No newline at end of file diff --git a/packages/frontend/src/stores/session/types.ts b/packages/frontend/src/stores/session/types.ts index 051d628..fc0ce72 100644 --- a/packages/frontend/src/stores/session/types.ts +++ b/packages/frontend/src/stores/session/types.ts @@ -41,6 +41,8 @@ export interface SessionState { activeEditorTabId: Ref; // 当前活动的编辑器标签页 ID // --- 新增:命令输入框内容 --- commandInputContent: Ref; // 当前会话的命令输入框内容 + isResuming?: boolean; // 新增:标记会话是否正在从挂起状态恢复 + disposables?: (() => void)[]; // 新增:用于存储清理函数,例如取消注册消息处理器 } // 为标签栏定义包含状态的类型 diff --git a/packages/frontend/src/types/ssh-suspend.types.ts b/packages/frontend/src/types/ssh-suspend.types.ts new file mode 100644 index 0000000..3d5e315 --- /dev/null +++ b/packages/frontend/src/types/ssh-suspend.types.ts @@ -0,0 +1,33 @@ +/** + * 表示从后端获取的单个挂起 SSH 会话的详细信息。 + */ +export interface SuspendedSshSession { + /** 挂起会话的唯一ID。 */ + suspendSessionId: string; + /** 原始连接的名称,通常是主机名或用户定义的连接别名。 */ + connectionName: string; + /** 原始连接的ID。 */ + connectionId: string; + /** 会话挂起的开始时间,ISO 格式的日期字符串。 */ + suspendStartTime: string; + /** 用户为该挂起会话自定义的名称。 */ + customSuspendName?: string; + /** + * 后端 SSH 连接的当前状态。 + * - 'hanging': SSH 连接仍在后端保持活跃。 + * - 'disconnected_by_backend': SSH 连接已从后端意外断开。 + */ + backendSshStatus: 'hanging' | 'disconnected_by_backend'; + /** + * 如果连接已从后端断开 (backendSshStatus === 'disconnected_by_backend'), + * 则此字段表示断开连接的时间戳,ISO 格式的日期字符串。 + */ + disconnectionTimestamp?: string; +} + +/** + * SSH_SUSPEND_LIST_RESPONSE 消息的载荷结构。 + */ +export interface SshSuspendListResponsePayload { + suspendSessions: SuspendedSshSession[]; +} \ No newline at end of file diff --git a/packages/frontend/src/types/websocket.types.ts b/packages/frontend/src/types/websocket.types.ts index df9ce58..632ee74 100644 --- a/packages/frontend/src/types/websocket.types.ts +++ b/packages/frontend/src/types/websocket.types.ts @@ -15,3 +15,173 @@ export interface WebSocketMessage { // 消息处理器函数类型 export type MessageHandler = (payload: MessagePayload, message: WebSocketMessage) => void; // 恢复 message 参数为必需 + +// --- SSH Suspend Mode WebSocket Message Types --- + +// 导入挂起会话类型,用于相关消息的 payload +import type { SuspendedSshSession } from './ssh-suspend.types'; // 路径: packages/frontend/src/types/ssh-suspend.types.ts + +// --- Client to Server (C2S) Message Payloads --- +export interface SshSuspendStartReqPayload { + sessionId: string; +} + +export interface SshSuspendResumeReqPayload { + suspendSessionId: string; + newFrontendSessionId: string; +} + +export interface SshSuspendTerminateReqPayload { + suspendSessionId: string; +} + +export interface SshSuspendRemoveEntryReqPayload { + suspendSessionId: string; +} + +export interface SshSuspendEditNameReqPayload { + suspendSessionId: string; + customName: string; +} + +// --- Server to Client (S2C) Message Payloads --- +export interface SshSuspendStartedRespPayload { + frontendSessionId: string; + suspendSessionId: string; + success: boolean; + error?: string; +} + +export interface SshSuspendListResponsePayload { + suspendSessions: SuspendedSshSession[]; +} + +export interface SshSuspendResumedNotifPayload { + suspendSessionId: string; + newFrontendSessionId: string; + success: boolean; + error?: string; +} + +export interface SshOutputCachedChunkPayload { + frontendSessionId: string; + data: string; + isLastChunk: boolean; +} + +export interface SshSuspendTerminatedRespPayload { + suspendSessionId: string; + success: boolean; + error?: string; +} + +export interface SshSuspendEntryRemovedRespPayload { + suspendSessionId: string; + success: boolean; + error?: string; +} + +export interface SshSuspendNameEditedRespPayload { + suspendSessionId: string; + success: boolean; + customName?: string; + error?: string; +} + +export interface SshSuspendAutoTerminatedNotifPayload { + suspendSessionId: string; + reason: string; +} + +// --- Specific C2S Message Interfaces --- +export interface SshSuspendStartReqMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_START'; + payload: SshSuspendStartReqPayload; +} + +export interface SshSuspendListReqMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_LIST_REQUEST'; + payload?: {}; // 明确 payload 可以为空对象 +} + +export interface SshSuspendResumeReqMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_RESUME_REQUEST'; + payload: SshSuspendResumeReqPayload; +} + +export interface SshSuspendTerminateReqMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_TERMINATE_REQUEST'; + payload: SshSuspendTerminateReqPayload; +} + +export interface SshSuspendRemoveEntryReqMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_REMOVE_ENTRY'; + payload: SshSuspendRemoveEntryReqPayload; +} + +export interface SshSuspendEditNameReqMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_EDIT_NAME'; + payload: SshSuspendEditNameReqPayload; +} + +// --- Specific S2C Message Interfaces --- +export interface SshSuspendStartedRespMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_STARTED'; + payload: SshSuspendStartedRespPayload; +} + +export interface SshSuspendListResponseMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_LIST_RESPONSE'; + payload: SshSuspendListResponsePayload; +} + +export interface SshSuspendResumedNotifMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_RESUMED'; + payload: SshSuspendResumedNotifPayload; +} + +export interface SshOutputCachedChunkMessage extends WebSocketMessage { + type: 'SSH_OUTPUT_CACHED_CHUNK'; + payload: SshOutputCachedChunkPayload; +} + +export interface SshSuspendTerminatedRespMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_TERMINATED'; + payload: SshSuspendTerminatedRespPayload; +} + +export interface SshSuspendEntryRemovedRespMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_ENTRY_REMOVED'; + payload: SshSuspendEntryRemovedRespPayload; +} + +export interface SshSuspendNameEditedRespMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_NAME_EDITED'; + payload: SshSuspendNameEditedRespPayload; +} + +export interface SshSuspendAutoTerminatedNotifMessage extends WebSocketMessage { + type: 'SSH_SUSPEND_AUTO_TERMINATED'; + payload: SshSuspendAutoTerminatedNotifPayload; +} + +// Union type for all SSH Suspend related messages (optional, but can be useful) +export type SshSuspendC2SMessage = + | SshSuspendStartReqMessage + | SshSuspendListReqMessage + | SshSuspendResumeReqMessage + | SshSuspendTerminateReqMessage + | SshSuspendRemoveEntryReqMessage + | SshSuspendEditNameReqMessage; + +export type SshSuspendS2CMessage = + | SshSuspendStartedRespMessage + | SshSuspendListResponseMessage + | SshSuspendResumedNotifMessage + | SshOutputCachedChunkMessage + | SshSuspendTerminatedRespMessage + | SshSuspendEntryRemovedRespMessage + | SshSuspendNameEditedRespMessage + | SshSuspendAutoTerminatedNotifMessage; + +export type AllSshSuspendMessages = SshSuspendC2SMessage | SshSuspendS2CMessage; diff --git a/packages/frontend/src/views/SuspendedSshSessionsView.vue b/packages/frontend/src/views/SuspendedSshSessionsView.vue new file mode 100644 index 0000000..c0d7448 --- /dev/null +++ b/packages/frontend/src/views/SuspendedSshSessionsView.vue @@ -0,0 +1,281 @@ + + + + + \ No newline at end of file