This commit is contained in:
Baobhan Sith
2025-05-10 00:00:31 +08:00
parent 36afe3d5c2
commit c36e961426
29 changed files with 2268 additions and 41 deletions
+2
View File
@@ -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) => {
@@ -382,7 +382,7 @@ export const settingsService = {
const validPaneNames: Set<PaneName> = new Set([
'connections', 'terminal', 'commandBar', 'fileManager',
'editor', 'statusMonitor', 'commandHistory', 'quickCommands',
'dockerManager'
'dockerManager', 'suspendedSshSessions' // 添加 "suspendedSshSessions"
]);
const validatePaneArray = (arr: any[], side: string) => {
@@ -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<suspendSessionId, SuspendSessionDetails>。
*/
private getUserSessions(userId: number): Map<string, SuspendSessionDetails> { // userId: string -> number
if (!this.suspendedSessions.has(userId)) {
this.suspendedSessions.set(userId, new Map<string, SuspendSessionDetails>());
}
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<string> 返回生成的 suspendSessionId。
*/
async startSuspend(
userId: number, // userId: string -> number
originalSessionId: string,
sshClient: Client,
channel: ClientChannel, // 更新为 ClientChannel
connectionName: string,
connectionId: string,
customSuspendName?: string,
): Promise<string> {
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<SuspendedSessionInfo[]> 挂起会话信息的数组。
*/
async listSuspendedSessions(userId: number): Promise<SuspendedSessionInfo[]> { // 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<boolean> 操作是否成功。
*/
async terminateSuspendedSession(userId: number, suspendSessionId: string): Promise<boolean> { // 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<boolean> 操作是否成功。
*/
async removeDisconnectedSessionEntry(userId: number, suspendSessionId: string): Promise<boolean> { // 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<boolean> 操作是否成功。
*/
async editSuspendedSessionName(userId: number, suspendSessionId: string, newCustomName: string): Promise<boolean> { // 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();
@@ -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<void> {
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<void> {
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<string> {
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<void> {
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<string[]> {
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();
@@ -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<void> {
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<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] 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<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] 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' });
}
}
}
}
@@ -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;
+1 -1
View File
@@ -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)
@@ -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<suspendSessionId, SuspendSessionDetails>
*/
export type SuspendedSessionsMap = Map<number, Map<string, SuspendSessionDetails>>;
/**
* 用于API响应的挂起会话信息子集
*/
export interface SuspendedSessionInfo {
suspendSessionId: string;
connectionName: string;
connectionId: string;
suspendStartTime: string;
customSuspendName?: string;
backendSshStatus: BackendSshStatus;
disconnectionTimestamp?: string;
}
+24 -3
View File
@@ -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<WebSocketServer> => {
// 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', () => {
+237 -3
View File
@@ -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.');
}
+155 -1
View File
@@ -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<string, string>;
stats?: DockerStats | null; // 可选的 stats 字段
}
}
// --- 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.
+9 -2
View File
@@ -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) {
@@ -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 ---
@@ -79,6 +79,7 @@ const componentMap: Record<PaneName, Component> = {
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
}
@@ -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"
<span class="truncate text-sm" style="transform: translateY(-1px);">{{ session.connectionName }}</span>
<button class="ml-2 p-0.5 rounded-full text-text-secondary hover:bg-border hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-150"
:class="{'text-foreground hover:bg-header': session.sessionId === activeSessionId}"
@click="closeSession($event, session.sessionId)" title="关闭标签页">
@click="closeSession($event, session.sessionId)" :title="$t('tabs.closeTabTooltip')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -324,7 +359,7 @@ animation="150"
</draggable>
<!-- Add Tab Button -->
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
@click="togglePopup" title="新建连接标签页">
@click="togglePopup" :title="$t('tabs.newTabTooltip')">
<i class="fas fa-plus text-sm"></i>
</button>
</div>
@@ -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<typeof useI18n>['t']) { // +++ Update type of t +++
// 使用依赖注入的 WebSocket 函数
const { sendMessage, onMessage, isConnected } = wsDeps;
@@ -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<typeof useI18n>['t']) { // +++ Update type of t +++
// --- Instance State ---
// 每个实例拥有独立的 WebSocket 对象、状态和消息处理器
const ws = shallowRef<WebSocket | null>(null); // WebSocket 实例
+6 -1
View File
@@ -26,7 +26,12 @@ if (availableLocales.length === 0) {
// 类型推断 (基于第一个加载的语言文件,假设所有文件结构一致)
// 如果没有加载到文件,则使用空对象作为 fallback,避免运行时错误
// 使用更通用的类型 Record<string, any> 来避免动态索引的类型推断问题
type MessageSchema = Record<string, any>;
// 尝试一个更具体的类型来帮助 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
+34 -4
View File
@@ -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"
}
}
+34 -4
View File
@@ -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": "无效日期"
}
}
+2 -2
View File
@@ -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<PaneName[]> = ref([
'connections', 'terminal', 'commandBar', 'fileManager',
'editor', 'statusMonitor', 'commandHistory', 'quickCommands',
'dockerManager' // <--- 在这里添加 'dockerManager'
'dockerManager', 'suspendedSshSessions' // <-- 添加新的挂起 SSH 会话视图
]);
// 新增:控制布局(Header/Footer)可见性的状态
const isLayoutVisible: Ref<boolean> = ref(true); // 控制整体布局(Header/Footer)可见性
+12 -2
View File
@@ -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,
};
});
@@ -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<typeof useConnectionsStore>;
t: ReturnType<typeof useI18n>['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 = () => {
@@ -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<void> => {
isLoadingSuspendedSessions.value = true;
try {
// 假设后端 API 端点为 /api/ssh/suspended-sessions
// 并且它返回 SuspendedSshSession[] 类型的数据
const response = await apiClient.get<SuspendedSshSession[]>('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<void> => {
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<void> => {
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<void> => {
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();
};
@@ -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<Map<string, SessionState>>(new Map());
@@ -15,4 +16,8 @@ export const rdpConnectionInfo = ref<ConnectionInfo | null>(null);
// --- VNC Modal State ---
export const isVncModalOpen = ref(false);
export const vncConnectionInfo = ref<ConnectionInfo | null>(null);
export const vncConnectionInfo = ref<ConnectionInfo | null>(null);
// --- SSH Suspend Mode State ---
export const suspendedSshSessions = ref<SuspendedSshSession[]>([]);
export const isLoadingSuspendedSessions = ref<boolean>(false);
@@ -41,6 +41,8 @@ export interface SessionState {
activeEditorTabId: Ref<string | null>; // 当前活动的编辑器标签页 ID
// --- 新增:命令输入框内容 ---
commandInputContent: Ref<string>; // 当前会话的命令输入框内容
isResuming?: boolean; // 新增:标记会话是否正在从挂起状态恢复
disposables?: (() => void)[]; // 新增:用于存储清理函数,例如取消注册消息处理器
}
// 为标签栏定义包含状态的类型
@@ -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[];
}
@@ -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;
@@ -0,0 +1,281 @@
<template>
<div class="suspended-ssh-sessions-view p-2 flex flex-col h-full">
<div class="view-header mb-2">
<div class="relative w-full">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-text-secondary"></i>
</span>
<input
type="text"
v-model="searchTerm"
:placeholder="$t('suspendedSshSessions.searchPlaceholder')"
class="w-full pl-10 pr-4 py-2 border border-border rounded-md bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
@input="filterSessions"
/>
</div>
<!-- 可选显示挂起会话总数 -->
<!-- <div class="text-sm text-gray-500 mt-1">
当前挂起会话总数: {{ filteredSessions.length }} / {{ allSuspendedSshSessions.length }}
</div> -->
</div>
<div class="session-list-container flex-grow overflow-y-auto">
<div v-if="isLoading" class="text-center p-4">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem"></i>
<p>{{ $t('suspendedSshSessions.loading') }}</p>
</div>
<div v-else-if="filteredSessions.length === 0 && !isLoading" class="text-center p-4">
<p>{{ $t('suspendedSshSessions.noResults') }}</p>
</div>
<ul v-else class="list-none p-0 m-0">
<li
v-for="session in filteredSessions"
:key="session.suspendSessionId"
class="session-item p-3 mb-2 border rounded-md bg-surface-ground"
:class="{ 'opacity-60': session.backendSshStatus === 'disconnected_by_backend' }"
>
<div class="flex justify-between items-center">
<div class="session-info flex-grow mr-2">
<div class="font-bold text-lg">
<span
v-if="!session.isEditingName"
class="cursor-pointer hover:text-primary"
:title="$t('suspendedSshSessions.tooltip.editName')"
@click="startEditingName(session)"
>
{{ session.customSuspendName || session.connectionName }}
</span>
<input
v-else
v-model="session.editingNameValue"
type="text"
class="text-lg font-bold w-full px-1 py-0.5 border border-primary rounded-md bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
autofocus
@blur="finishEditingName(session)"
@keydown.enter.prevent="finishEditingName(session)"
@keydown.esc.prevent="cancelEditingName(session)"
/>
</div>
<div class="text-sm text-muted-color">
{{ $t('suspendedSshSessions.label.originalConnection') }}: {{ session.connectionName }}
</div>
<div class="text-xs text-muted-color mt-1">
{{ $t('suspendedSshSessions.label.suspendedAt') }}: {{ formatDateTime(session.suspendStartTime) }}
</div>
<div
v-if="session.backendSshStatus === 'disconnected_by_backend' && session.disconnectionTimestamp"
class="text-xs text-orange-500 mt-1"
>
{{ $t('suspendedSshSessions.disconnectedAt', { time: formatDateTime(session.disconnectionTimestamp) }) }}
</div>
</div>
<div class="session-status-actions flex flex-col items-end space-y-2">
<span
:class="[
'px-2 py-1 text-xs font-semibold rounded-full',
session.backendSshStatus === 'hanging' ? 'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100'
]"
>
{{ session.backendSshStatus === 'hanging' ? $t('suspendedSshSessions.status.hanging') : $t('suspendedSshSessions.status.disconnected') }}
</span>
<div class="actions flex space-x-2">
<button
v-if="session.backendSshStatus === 'hanging'"
@click="resumeSession(session)"
:title="$t('suspendedSshSessions.action.resume')"
class="px-3 py-1.5 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-150 inline-flex items-center"
>
<i class="fas fa-play mr-1.5"></i>
{{ $t('suspendedSshSessions.action.resume') }}
</button>
<button
@click="removeSession(session)"
:title="$t('suspendedSshSessions.action.remove')"
class="px-3 py-1.5 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-150 inline-flex items-center"
>
<i class="fas fa-trash-alt mr-1.5"></i>
{{ $t('suspendedSshSessions.action.remove') }}
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia'; // +++ storeToRefs +++
// PrimeVue components (InputText, Button, Tag) are assumed to be globally registered
// based on the structure of other views like QuickCommandsView.vue
// and the nature of the 'Cannot find module' errors which might indicate
// they are not meant to be imported directly here if globally available.
// sessionStore
import { useSessionStore } from '../stores/session.store'; // 使 store
import type { SuspendedSshSession } from '../types/ssh-suspend.types'; // SuspendedSshSession
const { t } = useI18n();
// ssh-suspend.types.ts ()
interface SuspendedSshSessionUIData extends SuspendedSshSession {
isEditingName?: boolean;
editingNameValue?: string;
}
// // sessionStore ()
// const mockSessionStore = {
// suspendedSshSessions: ref<SuspendedSshSessionUIData[]>([
// // ... mock data ...
// ]),
// fetchSuspendedSshSessions: async () => {
// console.log('[SuspendedSshSessionsView] Requesting suspended SSH sessions...');
// // API
// return new Promise(resolve => setTimeout(() => {
// mockSessionStore.suspendedSshSessions.value = [
// // ... mock data ...
// ];
// isLoading.value = false;
// console.log('[SuspendedSshSessionsView] Mock sessions loaded:', mockSessionStore.suspendedSshSessions.value);
// resolve(true);
// }, 1500));
// },
// resumeSshSession: async (suspendSessionId: string, newFrontendSessionId: string) => {
// console.log(`[SuspendedSshSessionsView] Action: resumeSshSession(${suspendSessionId}, ${newFrontendSessionId})`);
// alert(`: ${suspendSessionId}`);
// },
// terminateAndRemoveSshSession: async (suspendSessionId: string) => {
// console.log(`[SuspendedSshSessionsView] Action: terminateAndRemoveSshSession(${suspendSessionId})`);
// mockSessionStore.suspendedSshSessions.value = mockSessionStore.suspendedSshSessions.value.filter(s => s.suspendSessionId !== suspendSessionId);
// alert(`: ${suspendSessionId}`);
// },
// removeSshSessionEntry: async (suspendSessionId: string) => {
// console.log(`[SuspendedSshSessionsView] Action: removeSshSessionEntry(${suspendSessionId})`);
// mockSessionStore.suspendedSshSessions.value = mockSessionStore.suspendedSshSessions.value.filter(s => s.suspendSessionId !== suspendSessionId);
// alert(`: ${suspendSessionId}`);
// },
// editSshSessionName: async (suspendSessionId: string, newName: string) => {
// console.log(`[SuspendedSshSessionsView] Action: editSshSessionName(${suspendSessionId}, ${newName})`);
// const session = mockSessionStore.suspendedSshSessions.value.find(s => s.suspendSessionId === suspendSessionId);
// if (session) {
// session.customSuspendName = newName;
// }
// alert(`: ${suspendSessionId} -> ${newName}`);
// },
// };
const sessionStore = useSessionStore(); // 使 store
// const sessionStore = mockSessionStore; // 使 store ()
// +++ 使 storeToRefs isLoadingSuspendedSessions isLoading +++
const { suspendedSshSessions: storeSuspendedSshSessions, isLoadingSuspendedSessions: isLoading } = storeToRefs(sessionStore);
const searchTerm = ref('');
// const isLoading = ref(true); // store isLoading
const allSuspendedSshSessions = computed(() => storeSuspendedSshSessions.value.map((s: SuspendedSshSession) => ({ // s
...(s as SuspendedSshSessionUIData), // UI
isEditingName: (s as SuspendedSshSessionUIData).isEditingName ?? false,
editingNameValue: (s as SuspendedSshSessionUIData).editingNameValue ?? s.customSuspendName ?? s.connectionName,
})));
const filteredSessions = computed(() => {
if (!searchTerm.value.trim()) {
return allSuspendedSshSessions.value; // allSuspendedSshSessions .value
}
const lowerSearchTerm = searchTerm.value.toLowerCase();
return allSuspendedSshSessions.value.filter((session: SuspendedSshSessionUIData) => // session
(session.customSuspendName?.toLowerCase() || '').includes(lowerSearchTerm) ||
session.connectionName.toLowerCase().includes(lowerSearchTerm)
);
});
const filterSessions = () => {
//
};
const formatDateTime = (isoString?: string) => {
if (!isoString) return t('time.unknown');
try {
return new Date(isoString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch (e) {
return t('time.invalidDate');
}
};
const startEditingName = (session: SuspendedSshSessionUIData) => {
//
allSuspendedSshSessions.value.forEach((s: SuspendedSshSessionUIData) => s.isEditingName = false); // s
session.isEditingName = true;
session.editingNameValue = session.customSuspendName || session.connectionName;
};
const finishEditingName = (session: SuspendedSshSessionUIData) => {
if (!session.isEditingName) return;
session.isEditingName = false;
const newName = session.editingNameValue?.trim();
//
if (newName && newName !== (session.customSuspendName || session.connectionName)) {
sessionStore.editSshSessionName(session.suspendSessionId, newName);
} else {
//
session.editingNameValue = session.customSuspendName || session.connectionName;
}
};
const cancelEditingName = (session: SuspendedSshSessionUIData) => {
session.isEditingName = false;
session.editingNameValue = session.customSuspendName || session.connectionName; //
};
const resumeSession = (session: SuspendedSshSessionUIData) => {
// newFrontendSessionId sessionStore
// const newFrontendSessionId = `new-session-${Date.now()}`; // newFrontendSessionId action
sessionStore.resumeSshSession(session.suspendSessionId); // +++ suspendSessionId +++
};
const removeSession = (session: SuspendedSshSessionUIData) => {
if (session.backendSshStatus === 'hanging') {
sessionStore.terminateAndRemoveSshSession(session.suspendSessionId);
} else if (session.backendSshStatus === 'disconnected_by_backend') {
sessionStore.removeSshSessionEntry(session.suspendSessionId);
}
};
onMounted(async () => {
// isLoading.value = true; // storeIsLoading
await sessionStore.fetchSuspendedSshSessions();
// isLoading.value = false; // fetchSuspendedSshSessions storeIsLoading
});
</script>
<style scoped>
.suspended-ssh-sessions-view {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}
.session-item {
transition: background-color 0.2s ease-in-out;
}
.session-item:hover {
background-color: var(--surface-hover); /* PrimeVue hover color */
}
/* 保持与 QuickCommandsView 类似的简洁风格 */
.p-inputtext-sm {
padding: 0.375rem 0.5rem; /* 调整输入框大小 */
font-size: 0.875rem;
}
</style>