feat: 添加挂起会话导出日志功能
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式导出
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -142,4 +143,41 @@ export class SshSuspendController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async exportSessionLog(req: Request, res: Response): Promise<void> {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +394,79 @@ export const editSshSessionName = async (suspendSessionId: string, newCustomName
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求导出指定挂起 SSH 会话的日志
|
||||
* @param suspendSessionId 要导出日志的挂起会话 ID
|
||||
*/
|
||||
export const exportSshSessionLog = async (suspendSessionId: string): Promise<void> => {
|
||||
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<Blob>(`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 不再需要,因为流程已改变
|
||||
|
||||
@@ -97,6 +97,15 @@
|
||||
<i class="fas fa-trash-alt action-icon" style="color: white;"></i>
|
||||
<span class="button-session-text">{{ $t('suspendedSshSessions.action.remove') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="session.backendSshStatus === 'disconnected_by_backend' || session.backendSshStatus === 'hanging'"
|
||||
@click="exportLog(session)"
|
||||
:title="$t('suspendedSshSessions.action.exportLog')"
|
||||
class="responsive-button-padding py-1.5 text-sm font-medium rounded-md text-button-text bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-150 inline-flex items-center"
|
||||
>
|
||||
<i class="fas fa-download action-icon" style="color: var(--button-text-color, white);"></i>
|
||||
<span class="button-session-text">{{ $t('suspendedSshSessions.action.exportLog') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user