diff --git a/packages/backend/src/services/ssh-suspend.service.ts b/packages/backend/src/services/ssh-suspend.service.ts index e69bbcd..6b879dc 100644 --- a/packages/backend/src/services/ssh-suspend.service.ts +++ b/packages/backend/src/services/ssh-suspend.service.ts @@ -403,6 +403,50 @@ export class SshSuspendService extends EventEmitter { // 确保所有已缓冲的日志已尝试写入 (通常由 'data' 事件处理,这里是最终状态确认) } } + + /** + * 获取指定挂起会话的日志内容。 + * 允许导出 'disconnected_by_backend' 和 'hanging' 状态的会话日志。 + * @param userId 用户ID。 + * @param suspendSessionId 要导出日志的挂起会话ID。 + * @returns Promise<{ content: string, filename: string } | null> 日志内容和建议的文件名,如果会话不符合条件或读取失败则返回null。 + */ + async getSessionLogContent(userId: number, suspendSessionId: string): Promise<{ content: string, filename: string } | null> { + console.log(`[SshSuspendService][用户: ${userId}] getSessionLogContent 调用,suspendSessionId: ${suspendSessionId}`); + const userSessions = this.getUserSessions(userId); + const session = userSessions.get(suspendSessionId); + + if (!session) { + console.warn(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 未找到挂起的会话 ${suspendSessionId}。`); + return null; + } + + if (session.backendSshStatus !== 'disconnected_by_backend' && session.backendSshStatus !== 'hanging') { + console.warn(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 会话 ${suspendSessionId} 状态为 ${session.backendSshStatus},不符合导出条件 (需要 'disconnected_by_backend' 或 'hanging')。`); + return null; + } + + if (!session.tempLogPath) { + console.error(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 会话 ${suspendSessionId} 缺少 tempLogPath。`); + return null; + } + + try { + const logContent = await this.logStorageService.readLog(session.tempLogPath); + console.log(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 已读取挂起会话 ${suspendSessionId} (日志: ${session.tempLogPath}) 的数据,长度: ${logContent.length}`); + + const baseName = session.customSuspendName || session.connectionName || suspendSessionId.substring(0,8); + const safeBaseName = baseName.replace(/[^\w.-]/g, '_'); // 替换掉不安全字符为空格或下划线 + const timestamp = new Date(session.suspendStartTime).toISOString().replace(/[:.]/g, '-'); + // tempLogPath 通常是 originalSessionId + const filename = `ssh_log_${safeBaseName}_${session.tempLogPath}_${timestamp}.log`; + + return { content: logContent, filename }; + } catch (error) { + console.error(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 读取挂起会话 ${suspendSessionId} (日志: ${session.tempLogPath}) 失败:`, error); + return null; + } + } } // 单例模式导出 diff --git a/packages/backend/src/ssh-suspend/ssh-suspend.controller.ts b/packages/backend/src/ssh-suspend/ssh-suspend.controller.ts index 993140c..ae3a06e 100644 --- a/packages/backend/src/ssh-suspend/ssh-suspend.controller.ts +++ b/packages/backend/src/ssh-suspend/ssh-suspend.controller.ts @@ -12,6 +12,7 @@ export class SshSuspendController { this.terminateAndRemoveSession = this.terminateAndRemoveSession.bind(this); this.removeSessionEntry = this.removeSessionEntry.bind(this); this.editSessionNameHttp = this.editSessionNameHttp.bind(this); // 绑定新方法 + this.exportSessionLog = this.exportSessionLog.bind(this); // +++ 绑定导出日志方法 +++ } public async getSuspendedSshSessions(req: Request, res: Response): Promise { @@ -142,4 +143,41 @@ export class SshSuspendController { } } } + + public async exportSessionLog(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] exportSessionLog called for user ID: ${userId}, suspendSessionId: ${suspendSessionId}`); + + const logData = await sshSuspendService.getSessionLogContent(userId, suspendSessionId); + + if (logData) { + res.setHeader('Content-Disposition', `attachment; filename="${logData.filename}"`); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.send(logData.content); + } else { + // sshSuspendService.getSessionLogContent 会记录详细的警告/错误 + // 如果会话不存在,或者状态不符合导出条件,或者读取日志失败 + res.status(404).json({ message: `Failed to export log for session ${suspendSessionId}. It might not exist, not be in a valid state for export, or log reading failed.` }); + } + } catch (error) { + console.error(`[SshSuspendController] Error exporting session log for user ID: ${req.session.userId}, suspendSessionId: ${req.params.suspendSessionId}:`, error); + if (error instanceof Error) { + res.status(500).json({ message: 'Failed to export suspended session log', error: error.message }); + } else { + res.status(500).json({ message: 'Failed to export suspended session log', 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 index 4b422db..a6feb1a 100644 --- a/packages/backend/src/ssh-suspend/ssh-suspend.routes.ts +++ b/packages/backend/src/ssh-suspend/ssh-suspend.routes.ts @@ -34,4 +34,11 @@ router.put( sshSuspendController.editSessionNameHttp // 新的控制器方法 ); +// Route to export the log of a suspended session +router.get( + '/log/:suspendSessionId', + isAuthenticated, + sshSuspendController.exportSessionLog +); + export default router; \ No newline at end of file diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 931bcee..abe4cca 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -1191,7 +1191,8 @@ }, "action": { "resume": "Resume", - "remove": "Remove" + "remove": "Remove", + "exportLog": "Export Log" } }, "time": { @@ -1221,7 +1222,9 @@ "defaultSessionName": "Session", "resumeSuccess": "Session \"{name}\" resumed successfully.", "resumeErrorBackend": "Backend failed to resume session: {error}", - "autoTerminated": "Suspended session \"{name}\" was auto-terminated by the backend due to: {reason}" + "autoTerminated": "Suspended session \"{name}\" was auto-terminated by the backend due to: {reason}", + "logExportSuccess": "Suspended session log {name} has started downloading.", + "logExportError": "Failed to export suspended session log: {error}" } } } diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 1f9f4e6..b2a1867 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -1193,7 +1193,8 @@ }, "action": { "resume": "恢复", - "remove": "移除" + "remove": "移除", + "exportLog": "导出日志" } }, "time": { @@ -1223,7 +1224,9 @@ "defaultSessionName": "会话", "resumeSuccess": "会话 \"{name}\" 已成功恢复。", "resumeErrorBackend": "后端恢复会话失败: {error}", - "autoTerminated": "已挂起的会话 \"{name}\" 因以下原因被后端自动终止: {reason}" + "autoTerminated": "已挂起的会话 \"{name}\" 因以下原因被后端自动终止: {reason}", + "logExportSuccess": "已挂起会话日志 {name} 已开始下载。", + "logExportError": "导出已挂起会话日志失败: {error}" } } } diff --git a/packages/frontend/src/stores/session/actions/sshSuspendActions.ts b/packages/frontend/src/stores/session/actions/sshSuspendActions.ts index e1aae3b..b567831 100644 --- a/packages/frontend/src/stores/session/actions/sshSuspendActions.ts +++ b/packages/frontend/src/stores/session/actions/sshSuspendActions.ts @@ -394,6 +394,79 @@ export const editSshSessionName = async (suspendSessionId: string, newCustomName } }; +/** + * 请求导出指定挂起 SSH 会话的日志 + * @param suspendSessionId 要导出日志的挂起会话 ID + */ +export const exportSshSessionLog = async (suspendSessionId: string): Promise => { + const uiNotificationsStore = useUiNotificationsStore(); + console.log(`[${t('term.sshSuspend')}] 请求导出挂起会话日志 (ID: ${suspendSessionId})`); + + try { + // API 端点为 /api/v1/ssh-suspend/log/:suspendSessionId + // apiClient.get会自动处理Blob响应类型,并尝试触发下载 + // 我们需要获取建议的文件名,后端会在 Content-Disposition 头中提供 + const response = await apiClient.get(`ssh-suspend/log/${suspendSessionId}`, { + responseType: 'blob', // 重要:期望响应为 Blob + // 我们可以传递一个 onDownloadProgress 回调(如果 apiClient 支持的话) + }); + + // 从 Content-Disposition 获取文件名 + const contentDisposition = response.headers['content-disposition']; + let filename = `ssh_log_${suspendSessionId}.log`; // 默认文件名 + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i); + if (filenameMatch && filenameMatch.length > 1) { + filename = filenameMatch[1]; + } + } + + // 创建一个下载链接并点击它 + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); // 设置下载文件名 + document.body.appendChild(link); + link.click(); + + // 清理 + link.parentNode?.removeChild(link); + window.URL.revokeObjectURL(url); + + uiNotificationsStore.addNotification({ + type: 'success', + message: t('sshSuspend.notifications.logExportSuccess', { name: filename }), + }); + console.log(`[${t('term.sshSuspend')}] 挂起会话日志 ${filename} (ID: ${suspendSessionId}) 已开始下载。`); + + } catch (error: any) { + console.error(`[${t('term.sshSuspend')}] 导出挂起会话日志 ${suspendSessionId} 失败:`, error); + let errorMessage = t('term.unknownError'); + if (error.response && error.response.data) { + // 如果响应是 Blob 但我们期望 JSON 错误信息,需要特殊处理 + // 假设错误时后端会返回 JSON + if (error.response.data instanceof Blob && error.response.headers['content-type']?.includes('application/json')) { + try { + const errorJson = JSON.parse(await error.response.data.text()); + errorMessage = errorJson.message || errorMessage; + } catch (e) { + // Blob 不是有效的 JSON,使用通用错误 + } + } else if (typeof error.response.data === 'object') { + errorMessage = error.response.data.message || error.message; + } else { + errorMessage = error.message; + } + } else { + errorMessage = error.message || String(error); + } + uiNotificationsStore.addNotification({ + type: 'error', + message: t('sshSuspend.notifications.logExportError', { error: errorMessage }), + }); + } +}; + // --- S2C Message Handlers --- // 旧的 handleSshSuspendStartedResp 不再需要,因为流程已改变 diff --git a/packages/frontend/src/views/SuspendedSshSessionsView.vue b/packages/frontend/src/views/SuspendedSshSessionsView.vue index 74701f5..db5e2c8 100644 --- a/packages/frontend/src/views/SuspendedSshSessionsView.vue +++ b/packages/frontend/src/views/SuspendedSshSessionsView.vue @@ -97,6 +97,15 @@ {{ $t('suspendedSshSessions.action.remove') }} + @@ -250,6 +259,12 @@ const removeSession = (session: SuspendedSshSession) => { // 参数类型改为 emitWorkspaceEvent('suspendedSession:actionCompleted'); }; +const exportLog = async (session: SuspendedSshSession) => { + console.log(`[SuspendedSshSessionsView] Attempting to export log for session ID: ${session.suspendSessionId}`); + await sessionStore.exportSshSessionLog(session.suspendSessionId); + // 不需要 emitWorkspaceEvent,因为导出日志通常不关闭模态框 +}; + let fetchIntervalId: number | undefined; onMounted(async () => {