diff --git a/backend/pom.xml b/backend/pom.xml index a0ea5880..15df8042 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -82,6 +82,12 @@ spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-websocket + + com.mysql diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/ThreadPoolConfig.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/ThreadPoolConfig.java index 5621c5cc..e91055df 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/ThreadPoolConfig.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/ThreadPoolConfig.java @@ -122,4 +122,40 @@ public class ThreadPoolConfig { executor.initialize(); return executor; } + + /** + * SSH输出监听线程池 + * 用于异步读取SSH输出流并推送到WebSocket + */ + @Bean("sshOutputExecutor") + public ThreadPoolTaskExecutor sshOutputExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + // 核心线程数:预期同时活跃的SSH连接数 + executor.setCorePoolSize(10); + + // 最大线程数:支持的最大SSH连接数 + executor.setMaxPoolSize(50); + + // 队列容量:等待处理的SSH输出监听任务 + executor.setQueueCapacity(100); + + // 线程名前缀 + executor.setThreadNamePrefix("ssh-output-"); + + // 线程空闲时间:SSH会话关闭后,线程60秒后回收 + executor.setKeepAliveSeconds(60); + + // 拒绝策略:由调用线程处理(确保SSH输出不会丢失) + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + + // 等待所有任务完成再关闭线程池 + executor.setWaitForTasksToCompleteOnShutdown(true); + + // 等待时间(秒) + executor.setAwaitTerminationSeconds(30); + + executor.initialize(); + return executor; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/WebSocketAuthInterceptor.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/WebSocketAuthInterceptor.java new file mode 100644 index 00000000..efbe5497 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/WebSocketAuthInterceptor.java @@ -0,0 +1,147 @@ +package com.qqchen.deploy.backend.deploy.config; + +import com.qqchen.deploy.backend.framework.security.CustomUserDetails; +import com.qqchen.deploy.backend.framework.security.util.JwtTokenUtil; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +/** + * WebSocket认证拦截器 + * 在WebSocket握手时验证JWT Token + */ +@Slf4j +@Component +public class WebSocketAuthInterceptor implements HandshakeInterceptor { + + @Resource + private JwtTokenUtil jwtTokenUtil; + + @Resource + private UserDetailsService userDetailsService; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) throws Exception { + + log.debug("WebSocket握手认证开始: {}", request.getURI()); + + try { + // 1. 从请求中提取Token + String token = extractToken(request); + if (token == null || token.isEmpty()) { + log.warn("WebSocket连接缺少Token"); + return false; + } + + // 2. 解析Token获取用户名 + String username = jwtTokenUtil.getUsernameFromToken(token); + if (username == null) { + log.warn("无效的Token"); + return false; + } + + // 3. 加载用户信息 + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + if (userDetails == null) { + log.warn("用户不存在: {}", username); + return false; + } + + // 4. 验证Token + if (!jwtTokenUtil.validateToken(token, userDetails)) { + log.warn("Token验证失败: {}", username); + return false; + } + + // 5. 将用户信息存入attributes,供Handler使用 + if (userDetails instanceof CustomUserDetails) { + CustomUserDetails customUserDetails = (CustomUserDetails) userDetails; + attributes.put("userId", customUserDetails.getUserId()); + attributes.put("username", customUserDetails.getUsername()); + + log.info("WebSocket认证成功: userId={}, username={}", + customUserDetails.getUserId(), customUserDetails.getUsername()); + } + + // 6. 提取客户端信息 + if (request instanceof ServletServerHttpRequest) { + ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; + String clientIp = getClientIp(servletRequest); + String userAgent = servletRequest.getServletRequest().getHeader("User-Agent"); + + attributes.put("clientIp", clientIp); + attributes.put("userAgent", userAgent); + } + + return true; + + } catch (Exception e) { + log.error("WebSocket认证异常", e); + return false; + } + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + // 握手后处理(可选) + } + + /** + * 从请求中提取Token + * 支持两种方式: + * 1. URL参数: ?token=xxx + * 2. Header: Authorization: Bearer xxx + */ + private String extractToken(ServerHttpRequest request) { + // 1. 尝试从URL参数获取 + String query = request.getURI().getQuery(); + if (query != null && query.contains("token=")) { + String[] params = query.split("&"); + for (String param : params) { + if (param.startsWith("token=")) { + return param.substring(6); + } + } + } + + // 2. 尝试从Header获取 + if (request instanceof ServletServerHttpRequest) { + ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; + String authHeader = servletRequest.getServletRequest().getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + } + + return null; + } + + /** + * 获取客户端真实IP + */ + private String getClientIp(ServletServerHttpRequest request) { + String ip = request.getServletRequest().getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getServletRequest().getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getServletRequest().getRemoteAddr(); + } + // 如果是多级代理,取第一个IP + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/WebSocketConfig.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/WebSocketConfig.java new file mode 100644 index 00000000..c4704510 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/config/WebSocketConfig.java @@ -0,0 +1,34 @@ +package com.qqchen.deploy.backend.deploy.config; + +import com.qqchen.deploy.backend.deploy.handler.ServerSSHWebSocketHandler; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * WebSocket配置类 + */ +@Slf4j +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + @Resource + private ServerSSHWebSocketHandler serverSSHWebSocketHandler; + + @Resource + private WebSocketAuthInterceptor webSocketAuthInterceptor; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + log.info("注册WebSocket处理器: /api/v1/server-ssh/connect/*"); + + // 注册SSH终端WebSocket处理器(添加认证拦截器) + registry.addHandler(serverSSHWebSocketHandler, "/api/v1/server-ssh/connect/{serverId}") + .addInterceptors(webSocketAuthInterceptor) // 添加认证拦截器 + .setAllowedOrigins("*"); // 生产环境建议配置具体的域名 + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/SSHAuditLogConverter.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/SSHAuditLogConverter.java new file mode 100644 index 00000000..278215e6 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/SSHAuditLogConverter.java @@ -0,0 +1,13 @@ +package com.qqchen.deploy.backend.deploy.converter; + +import com.qqchen.deploy.backend.deploy.dto.SSHAuditLogDTO; +import com.qqchen.deploy.backend.deploy.entity.SSHAuditLog; +import com.qqchen.deploy.backend.framework.converter.BaseConverter; +import org.mapstruct.Mapper; + +/** + * SSH审计日志转换器 + */ +@Mapper(config = BaseConverter.class) +public interface SSHAuditLogConverter extends BaseConverter { +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/SSHAuditLogDTO.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/SSHAuditLogDTO.java new file mode 100644 index 00000000..1573f1ff --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/SSHAuditLogDTO.java @@ -0,0 +1,62 @@ +package com.qqchen.deploy.backend.deploy.dto; + +import com.qqchen.deploy.backend.framework.dto.BaseDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * SSH审计日志DTO + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "SSH审计日志") +public class SSHAuditLogDTO extends BaseDTO { + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "服务器ID") + private Long serverId; + + @Schema(description = "服务器名称") + private String serverName; + + @Schema(description = "服务器IP") + private String serverIp; + + @Schema(description = "会话ID") + private String sessionId; + + @Schema(description = "连接时间") + private LocalDateTime connectTime; + + @Schema(description = "断开时间") + private LocalDateTime disconnectTime; + + @Schema(description = "会话时长(秒)") + private Integer durationSeconds; + + @Schema(description = "客户端IP") + private String clientIp; + + @Schema(description = "浏览器UA") + private String userAgent; + + @Schema(description = "执行命令数量") + private Integer commandCount; + + @Schema(description = "执行的命令记录(JSON)") + private String commands; + + @Schema(description = "状态") + private String status; + + @Schema(description = "错误信息") + private String errorMessage; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/SSHMessage.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/SSHMessage.java new file mode 100644 index 00000000..8b95192d --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/SSHMessage.java @@ -0,0 +1,83 @@ +package com.qqchen.deploy.backend.deploy.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.qqchen.deploy.backend.deploy.enums.SSHMessageTypeEnum; +import com.qqchen.deploy.backend.deploy.enums.SSHStatusEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * SSH WebSocket消息DTO + * + * 消息格式规范: + * 1. 状态消息:{"type":"status","status":"connecting"} + * 2. SSH输出:{"type":"output","data":"..."} + * 3. 错误消息:{"type":"error","message":"..."} + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) // 只序列化非null字段 +public class SSHMessage { + + /** + * 消息类型 + */ + private SSHMessageTypeEnum type; + + /** + * 消息数据(input/output时使用) + */ + private String data; + + /** + * 错误消息(error时使用) + */ + private String message; + + /** + * 连接状态(仅type=STATUS时使用) + */ + private SSHStatusEnum status; + + /** + * 创建input类型消息 + */ + public static SSHMessage input(String data) { + SSHMessage msg = new SSHMessage(); + msg.setType(SSHMessageTypeEnum.INPUT); + msg.setData(data); + return msg; + } + + /** + * 创建output类型消息 + */ + public static SSHMessage output(String data) { + SSHMessage msg = new SSHMessage(); + msg.setType(SSHMessageTypeEnum.OUTPUT); + msg.setData(data); + return msg; + } + + /** + * 创建error类型消息 + */ + public static SSHMessage error(String message) { + SSHMessage msg = new SSHMessage(); + msg.setType(SSHMessageTypeEnum.ERROR); + msg.setMessage(message); + return msg; + } + + /** + * 创建status类型消息 + */ + public static SSHMessage status(SSHStatusEnum status) { + SSHMessage msg = new SSHMessage(); + msg.setType(SSHMessageTypeEnum.STATUS); + msg.setStatus(status); + return msg; + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/SSHAuditLog.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/SSHAuditLog.java new file mode 100644 index 00000000..f1a79c39 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/SSHAuditLog.java @@ -0,0 +1,109 @@ +package com.qqchen.deploy.backend.deploy.entity; + +import com.qqchen.deploy.backend.framework.domain.Entity; +import jakarta.persistence.Column; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * SSH终端审计日志实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@jakarta.persistence.Entity +@Table(name = "deploy_ssh_audit_log") +public class SSHAuditLog extends Entity { + + /** + * 用户ID + */ + @Column(name = "user_id", nullable = false) + private Long userId; + + /** + * 用户名(冗余) + */ + @Column(name = "username", length = 50) + private String username; + + /** + * 服务器ID + */ + @Column(name = "server_id", nullable = false) + private Long serverId; + + /** + * 服务器名称(冗余) + */ + @Column(name = "server_name", length = 100) + private String serverName; + + /** + * 服务器IP(冗余) + */ + @Column(name = "server_ip", length = 50) + private String serverIp; + + /** + * WebSocket会话ID + */ + @Column(name = "session_id", nullable = false, length = 100) + private String sessionId; + + /** + * 连接时间 + */ + @Column(name = "connect_time", nullable = false) + private LocalDateTime connectTime; + + /** + * 断开时间 + */ + @Column(name = "disconnect_time") + private LocalDateTime disconnectTime; + + /** + * 会话时长(秒) + */ + @Column(name = "duration_seconds") + private Integer durationSeconds; + + /** + * 客户端IP + */ + @Column(name = "client_ip", length = 50) + private String clientIp; + + /** + * 浏览器UA + */ + @Column(name = "user_agent", length = 500) + private String userAgent; + + /** + * 执行命令数量 + */ + @Column(name = "command_count", nullable = false) + private Integer commandCount = 0; + + /** + * 执行的命令记录(JSON数组) + */ + @Column(name = "commands", columnDefinition = "LONGTEXT") + private String commands; + + /** + * 状态:SUCCESS成功, FAILED失败, TIMEOUT超时, INTERRUPTED中断 + */ + @Column(name = "status", length = 20) + private String status; + + /** + * 错误信息 + */ + @Column(name = "error_message", length = 500) + private String errorMessage; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/enums/SSHMessageTypeEnum.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/enums/SSHMessageTypeEnum.java new file mode 100644 index 00000000..2362ed45 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/enums/SSHMessageTypeEnum.java @@ -0,0 +1,22 @@ +package com.qqchen.deploy.backend.deploy.enums; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * SSH WebSocket消息类型枚举 + */ +@Getter +@AllArgsConstructor +public enum SSHMessageTypeEnum { + + INPUT("input", "用户输入"), + OUTPUT("output", "SSH输出"), + ERROR("error", "错误消息"), + STATUS("status", "连接状态"); + + @JsonValue // JSON序列化时使用此字段的值 + private final String value; + private final String description; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/enums/SSHStatusEnum.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/enums/SSHStatusEnum.java new file mode 100644 index 00000000..06532b75 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/enums/SSHStatusEnum.java @@ -0,0 +1,21 @@ +package com.qqchen.deploy.backend.deploy.enums; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * SSH连接状态枚举 + */ +@Getter +@AllArgsConstructor +public enum SSHStatusEnum { + + CONNECTING("connecting", "连接中"), + CONNECTED("connected", "已连接"), + DISCONNECTED("disconnected", "已断开"); + + @JsonValue // JSON序列化时使用此字段的值 + private final String value; + private final String description; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/query/SSHAuditLogQuery.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/query/SSHAuditLogQuery.java new file mode 100644 index 00000000..be6f27a6 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/query/SSHAuditLogQuery.java @@ -0,0 +1,51 @@ +package com.qqchen.deploy.backend.deploy.query; + +import com.qqchen.deploy.backend.framework.annotation.QueryField; +import com.qqchen.deploy.backend.framework.enums.QueryType; +import com.qqchen.deploy.backend.framework.query.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * SSH审计日志查询条件 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "SSH审计日志查询条件") +public class SSHAuditLogQuery extends BaseQuery { + + @Schema(description = "用户ID") + @QueryField(field = "userId") + private Long userId; + + @Schema(description = "用户名") + @QueryField(field = "username", type = QueryType.LIKE) + private String username; + + @Schema(description = "服务器ID") + @QueryField(field = "serverId") + private Long serverId; + + @Schema(description = "服务器IP") + @QueryField(field = "serverIp", type = QueryType.LIKE) + private String serverIp; + + @Schema(description = "会话ID") + @QueryField(field = "sessionId") + private String sessionId; + + @Schema(description = "状态") + @QueryField(field = "status") + private String status; + + @Schema(description = "连接开始时间") + @QueryField(field = "connectTime", type = QueryType.START_WITH) + private LocalDateTime connectTimeStart; + + @Schema(description = "连接结束时间") + @QueryField(field = "connectTime", type = QueryType.END_WITH) + private LocalDateTime connectTimeEnd; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ISSHAuditLogRepository.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ISSHAuditLogRepository.java new file mode 100644 index 00000000..2ea346fa --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ISSHAuditLogRepository.java @@ -0,0 +1,22 @@ +package com.qqchen.deploy.backend.deploy.repository; + +import com.qqchen.deploy.backend.deploy.entity.SSHAuditLog; +import com.qqchen.deploy.backend.framework.repository.IBaseRepository; +import org.springframework.stereotype.Repository; + +/** + * SSH审计日志Repository + */ +@Repository +public interface ISSHAuditLogRepository extends IBaseRepository { + + /** + * 根据会话ID查询审计日志 + */ + SSHAuditLog findBySessionId(String sessionId); + + /** + * 统计用户当前活跃的SSH会话数 + */ + long countByUserIdAndDisconnectTimeIsNull(Long userId); +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/ISSHAuditLogService.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/ISSHAuditLogService.java new file mode 100644 index 00000000..a1a12b54 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/ISSHAuditLogService.java @@ -0,0 +1,50 @@ +package com.qqchen.deploy.backend.deploy.service; + +import com.qqchen.deploy.backend.deploy.dto.SSHAuditLogDTO; +import com.qqchen.deploy.backend.deploy.entity.SSHAuditLog; +import com.qqchen.deploy.backend.deploy.entity.Server; +import com.qqchen.deploy.backend.deploy.query.SSHAuditLogQuery; +import com.qqchen.deploy.backend.framework.service.IBaseService; + +/** + * SSH审计日志服务接口 + */ +public interface ISSHAuditLogService extends IBaseService { + + /** + * 创建审计日志(连接时) + * + * @param userId 用户ID + * @param server 服务器信息 + * @param sessionId WebSocket会话ID + * @param clientIp 客户端IP + * @param userAgent 浏览器UA + * @return 审计日志ID + */ + Long createAuditLog(Long userId, Server server, String sessionId, String clientIp, String userAgent); + + /** + * 记录命令(每次用户输入) + * + * @param sessionId WebSocket会话ID + * @param command 用户输入的命令 + */ + void recordCommand(String sessionId, String command); + + /** + * 关闭审计日志(断开时) + * + * @param sessionId WebSocket会话ID + * @param status 结束状态 + * @param errorMessage 错误信息 + */ + void closeAuditLog(String sessionId, String status, String errorMessage); + + /** + * 检查用户当前活跃的SSH会话数 + * + * @param userId 用户ID + * @return 活跃会话数 + */ + long countUserActiveSessions(Long userId); +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/SSHAuditLogServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/SSHAuditLogServiceImpl.java new file mode 100644 index 00000000..63d41b15 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/SSHAuditLogServiceImpl.java @@ -0,0 +1,181 @@ +package com.qqchen.deploy.backend.deploy.service.impl; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.qqchen.deploy.backend.deploy.dto.SSHAuditLogDTO; +import com.qqchen.deploy.backend.deploy.entity.SSHAuditLog; +import com.qqchen.deploy.backend.deploy.entity.Server; +import com.qqchen.deploy.backend.deploy.query.SSHAuditLogQuery; +import com.qqchen.deploy.backend.deploy.repository.ISSHAuditLogRepository; +import com.qqchen.deploy.backend.deploy.service.ISSHAuditLogService; +import com.qqchen.deploy.backend.framework.annotation.ServiceType; +import com.qqchen.deploy.backend.framework.service.impl.BaseServiceImpl; +import com.qqchen.deploy.backend.framework.utils.JsonUtils; +import com.qqchen.deploy.backend.system.entity.User; +import com.qqchen.deploy.backend.system.service.IUserService; +import jakarta.annotation.Resource; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * SSH审计日志服务实现 + */ +@Slf4j +@Service +@ServiceType(ServiceType.Type.DATABASE) +public class SSHAuditLogServiceImpl + extends BaseServiceImpl + implements ISSHAuditLogService { + + private final ISSHAuditLogRepository auditLogRepository; + + @Resource + private IUserService userService; + + public SSHAuditLogServiceImpl(ISSHAuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createAuditLog(Long userId, Server server, String sessionId, String clientIp, String userAgent) { + log.info("创建SSH审计日志: userId={}, serverId={}, sessionId={}", userId, server.getId(), sessionId); + + SSHAuditLog auditLog = new SSHAuditLog(); + + // 用户信息 + auditLog.setUserId(userId); + User user = userService.findEntityById(userId); + if (user != null) { + auditLog.setUsername(user.getUsername()); + } + + // 服务器信息 + auditLog.setServerId(server.getId()); + auditLog.setServerName(server.getServerName()); + auditLog.setServerIp(server.getHostIp()); + + // 会话信息 + auditLog.setSessionId(sessionId); + auditLog.setConnectTime(LocalDateTime.now()); + + // 客户端信息 + auditLog.setClientIp(clientIp); + auditLog.setUserAgent(userAgent); + + // 初始化 + auditLog.setCommandCount(0); + auditLog.setCommands("[]"); + auditLog.setStatus("CONNECTED"); + + SSHAuditLog saved = auditLogRepository.save(auditLog); + log.info("SSH审计日志创建成功: id={}", saved.getId()); + + return saved.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void recordCommand(String sessionId, String command) { + try { + SSHAuditLog auditLog = auditLogRepository.findBySessionId(sessionId); + if (auditLog == null) { + log.warn("未找到审计日志: sessionId={}", sessionId); + return; + } + + // 解析现有命令列表 + List commands = parseCommands(auditLog.getCommands()); + + // 添加新命令 + CommandRecord record = new CommandRecord(); + record.setTimestamp(LocalDateTime.now()); + record.setCommand(command); + commands.add(record); + + // 更新 + auditLog.setCommands(toJson(commands)); + auditLog.setCommandCount(commands.size()); + auditLogRepository.save(auditLog); + + log.debug("记录命令: sessionId={}, commandCount={}", sessionId, commands.size()); + + } catch (Exception e) { + log.error("记录命令失败: sessionId={}", sessionId, e); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void closeAuditLog(String sessionId, String status, String errorMessage) { + try { + SSHAuditLog auditLog = auditLogRepository.findBySessionId(sessionId); + if (auditLog == null) { + log.warn("未找到审计日志: sessionId={}", sessionId); + return; + } + + auditLog.setDisconnectTime(LocalDateTime.now()); + auditLog.setStatus(status); + auditLog.setErrorMessage(errorMessage); + + // 计算会话时长 + if (auditLog.getConnectTime() != null) { + Duration duration = Duration.between(auditLog.getConnectTime(), auditLog.getDisconnectTime()); + auditLog.setDurationSeconds((int) duration.getSeconds()); + } + + auditLogRepository.save(auditLog); + + log.info("SSH审计日志关闭: sessionId={}, status={}, duration={}秒", + sessionId, status, auditLog.getDurationSeconds()); + + } catch (Exception e) { + log.error("关闭审计日志失败: sessionId={}", sessionId, e); + } + } + + @Override + public long countUserActiveSessions(Long userId) { + return auditLogRepository.countByUserIdAndDisconnectTimeIsNull(userId); + } + + /** + * 解析命令JSON + */ + private List parseCommands(String commandsJson) { + try { + if (commandsJson == null || commandsJson.trim().isEmpty() || "[]".equals(commandsJson.trim())) { + return new ArrayList<>(); + } + List result = JsonUtils.fromJson(commandsJson, new TypeReference>() {}); + return result != null ? result : new ArrayList<>(); + } catch (Exception e) { + log.error("解析命令JSON失败", e); + return new ArrayList<>(); + } + } + + /** + * 转换为JSON + */ + private String toJson(List commands) { + String json = JsonUtils.toJson(commands); + return json != null ? json : "[]"; + } + + /** + * 命令记录内部类 + */ + @Data + private static class CommandRecord { + private LocalDateTime timestamp; + private String command; + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/config/DependencyInjectionPostProcessor.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/config/DependencyInjectionPostProcessor.java index 0b0abbb5..669096c8 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/config/DependencyInjectionPostProcessor.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/config/DependencyInjectionPostProcessor.java @@ -13,6 +13,7 @@ import com.qqchen.deploy.backend.framework.service.IBaseService; import com.qqchen.deploy.backend.framework.service.impl.BaseServiceImpl; import com.qqchen.deploy.backend.framework.utils.ReflectionUtils; import com.querydsl.core.types.EntityPath; +import java.beans.Introspector; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanCreationException; @@ -206,8 +207,9 @@ public class DependencyInjectionPostProcessor implements BeanPostProcessor, Disp @SuppressWarnings("unchecked") private , D extends BaseDTO, Q extends BaseQuery, ID extends Serializable> void injectConverter(BaseServiceImpl service, Class entityClass) { - String converterBeanName = entityClass.getSimpleName().substring(0, 1).toLowerCase() - + entityClass.getSimpleName().substring(1) + "ConverterImpl"; + // 使用Java Bean规范的decapitalize方法,正确处理连续大写字母的情况 + // 例如:SSHAuditLog -> sshAuditLog, XMLParser -> xmlParser + String converterBeanName = Introspector.decapitalize(entityClass.getSimpleName()) + "ConverterImpl"; log.debug("正在查找 Converter: {}", converterBeanName); try { diff --git a/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/BaseNodeDelegate.java b/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/BaseNodeDelegate.java index 57a82e35..9e2c9c0c 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/BaseNodeDelegate.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/workflow/delegate/BaseNodeDelegate.java @@ -207,6 +207,8 @@ public abstract class BaseNodeDelegate implements JavaDelegate { /** * 解析Map中的表达式 * 自动处理纯表达式、模板字符串、纯文本三种情况 + * + * 重要:如果表达式解析失败(变量不存在),**不会设置该字段**,让JSON反序列化使用类型默认值 */ protected Map resolveExpressions(Map inputMap, DelegateExecution execution) { Map resolvedMap = new HashMap<>(); @@ -221,11 +223,20 @@ public abstract class BaseNodeDelegate implements JavaDelegate { try { // ✅ 使用 SpelExpressionResolver 统一解析方法,支持复杂表达式 String resolvedValue = SpelExpressionResolver.resolve(execution, strValue); - log.debug("解析字段: {} = {} -> {}", entry.getKey(), strValue, resolvedValue); - resolvedMap.put(entry.getKey(), resolvedValue); + + // ⚠️ 关键修复:如果解析后仍然是未解析的占位符(如${targetRepository.systemId}),说明变量不存在 + // 这种情况**不设置该字段**,让JSON反序列化时使用类型默认值(null或0等) + // 让具体的节点去校验参数是否有效,并抛出更友好的业务异常 + if (isUnresolvedPlaceholder(resolvedValue)) { + log.warn("表达式未解析成功(变量不存在): {} = {},跳过该字段", entry.getKey(), strValue); + // 不设置该字段到resolvedMap中 + } else { + log.debug("解析字段: {} = {} -> {}", entry.getKey(), strValue, resolvedValue); + resolvedMap.put(entry.getKey(), resolvedValue); + } } catch (Exception e) { - log.warn("解析失败: {},使用原始值", strValue, e); - resolvedMap.put(entry.getKey(), value); + log.warn("解析失败: {},跳过该字段", strValue, e); + // 不设置该字段到resolvedMap中 } } else { resolvedMap.put(entry.getKey(), value); @@ -235,6 +246,18 @@ public abstract class BaseNodeDelegate implements JavaDelegate { log.debug("解析后 resolvedMap: {}", resolvedMap); return resolvedMap; } + + /** + * 判断是否为未解析的占位符 + * 例如:${targetRepository.systemId}、${some.variable} + */ + private boolean isUnresolvedPlaceholder(String value) { + if (value == null || value.isEmpty()) { + return false; + } + // 检查是否以 ${ 开头且以 } 结尾,说明SpEL解析失败,保留了原样 + return value.startsWith("${") && value.endsWith("}"); + } protected String getFieldValue(Expression expression, DelegateExecution execution) { if (expression == null) return null; diff --git a/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql b/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql index b995e40d..d4003929 100644 --- a/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql +++ b/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql @@ -1187,3 +1187,48 @@ CREATE TABLE sys_notification_template KEY idx_channel_type (channel_type), KEY idx_enabled (enabled) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知模板表'; + +-- SSH终端审计日志表 +CREATE TABLE deploy_ssh_audit_log +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + create_by VARCHAR(100) NULL COMMENT '创建人', + create_time DATETIME(6) NULL COMMENT '创建时间', + update_by VARCHAR(100) NULL COMMENT '更新人', + update_time DATETIME(6) NULL COMMENT '更新时间', + version INT NOT NULL DEFAULT 1 COMMENT '版本号', + deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除', + + -- 基本信息 + user_id BIGINT NOT NULL COMMENT '用户ID', + username VARCHAR(50) NULL COMMENT '用户名(冗余)', + server_id BIGINT NOT NULL COMMENT '服务器ID', + server_name VARCHAR(100) NULL COMMENT '服务器名称(冗余)', + server_ip VARCHAR(50) NULL COMMENT '服务器IP(冗余)', + + -- 会话信息 + session_id VARCHAR(100) NOT NULL COMMENT 'WebSocket会话ID', + connect_time DATETIME(6) NOT NULL COMMENT '连接时间', + disconnect_time DATETIME(6) NULL COMMENT '断开时间', + duration_seconds INT NULL COMMENT '会话时长(秒)', + + -- 客户端信息 + client_ip VARCHAR(50) NULL COMMENT '客户端IP', + user_agent VARCHAR(500) NULL COMMENT '浏览器UA', + + -- 操作记录 + command_count INT NOT NULL DEFAULT 0 COMMENT '执行命令数量', + commands LONGTEXT NULL COMMENT '执行的命令记录(JSON数组)', + + -- 状态 + status VARCHAR(20) NULL COMMENT 'SUCCESS成功, FAILED失败, TIMEOUT超时, INTERRUPTED中断', + error_message VARCHAR(500) NULL COMMENT '错误信息', + + KEY idx_user_id (user_id), + KEY idx_server_id (server_id), + KEY idx_connect_time (connect_time), + KEY idx_session_id (session_id), + KEY idx_status (status), + CONSTRAINT fk_ssh_audit_user FOREIGN KEY (user_id) REFERENCES sys_user (id), + CONSTRAINT fk_ssh_audit_server FOREIGN KEY (server_id) REFERENCES deploy_server (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SSH终端审计日志表'; diff --git a/frontend/src/pages/Resource/Server/List/components/SSHTerminalDialog.tsx b/frontend/src/pages/Resource/Server/List/components/SSHTerminalDialog.tsx new file mode 100644 index 00000000..508de09f --- /dev/null +++ b/frontend/src/pages/Resource/Server/List/components/SSHTerminalDialog.tsx @@ -0,0 +1,492 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Terminal } from 'xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import 'xterm/css/xterm.css'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Loader2, XCircle } from 'lucide-react'; +import { message } from 'antd'; +import type { ServerResponse } from '../types'; + +// 添加自定义样式 +const customStyles = ` + /* 自定义滚动条样式 */ + .terminal-container .xterm-viewport::-webkit-scrollbar { + width: 8px; + } + + .terminal-container .xterm-viewport::-webkit-scrollbar-track { + background: #2d2d2d; + border-radius: 4px; + } + + .terminal-container .xterm-viewport::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; + } + + .terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: #666; + } + + /* 可调整大小的Dialog */ + .resizable-dialog { + resize: both; + overflow: hidden; + min-width: 600px; + min-height: 400px; + max-width: 98vw; + max-height: 95vh; + } +`; + +interface SSHTerminalDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + server: ServerResponse; +} + +type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error'; + +interface WebSocketMessage { + type: 'output' | 'error' | 'status'; + data?: string; + message?: string; + status?: ConnectionStatus; +} + +export const SSHTerminalDialog: React.FC = ({ + open, + onOpenChange, + server, +}) => { + const terminalRef = useRef(null); + const terminalInstanceRef = useRef(null); + const wsRef = useRef(null); + const fitAddonRef = useRef(null); + const [connectionStatus, setConnectionStatus] = useState('initializing'); + const [errorMessage, setErrorMessage] = useState(''); + const reconnectTimerRef = useRef(null); + const isClosingRef = useRef(false); + const dialogContentRef = useRef(null); + + // 初始化终端和建立连接 + const initializeTerminalAndConnect = useCallback(() => { + console.log('🚀 准备初始化SSH终端, terminalRef.current:', terminalRef.current); + + if (!terminalRef.current) { + console.error('❌ terminalRef.current 为 null,无法初始化'); + return; + } + + console.log('✅ 开始初始化SSH终端'); + setConnectionStatus('initializing'); + setErrorMessage(''); + + // 1. 初始化终端 + const terminal = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'Consolas, "Courier New", monospace', + theme: { + background: '#1e1e1e', + foreground: '#d4d4d4', + cursor: '#d4d4d4', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#e5e5e5', + }, + rows: 30, + cols: 100, + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + terminal.open(terminalRef.current); + + terminalInstanceRef.current = terminal; + fitAddonRef.current = fitAddon; + + // 延迟fit,确保容器已完全渲染 + setTimeout(() => { + fitAddon.fit(); + console.log('📐 终端尺寸已调整:', terminal.cols, 'x', terminal.rows); + }, 100); + + // 2. 监听终端输入 + terminal.onData((data) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + console.log('\u2328\ufe0f \u53d1\u9001\u8f93\u5165:', data.replace(/\r/g, '\\r').replace(/\n/g, '\\n')); + wsRef.current.send(JSON.stringify({ + type: 'input', + data: data, + })); + } else { + console.warn('\u26a0\ufe0f WebSocket\u672a\u8fde\u63a5\uff0c\u65e0\u6cd5\u53d1\u9001\u8f93\u5165'); + } + }); + + // 3. 延迟一点时间再连接,让loading状态显示出来 + setTimeout(() => { + connectWebSocket(); + }, 300); + }, [server.id]); + + useEffect(() => { + if (!open) return; + + // 延迟执行,确保 DOM 已经挂载 + const timer = setTimeout(() => { + initializeTerminalAndConnect(); + }, 50); + + // 窗口大小调整 + const handleResize = () => { + fitAddonRef.current?.fit(); + console.log('📐 窗口resize触发终端尺寸调整'); + }; + window.addEventListener('resize', handleResize); + + // 监听Dialog内容区域大小变化(用户拖动resize) + let resizeObserver: ResizeObserver | null = null; + if (terminalRef.current) { + resizeObserver = new ResizeObserver(() => { + fitAddonRef.current?.fit(); + console.log('📐 Dialog大小变化触发终端尺寸调整'); + }); + resizeObserver.observe(terminalRef.current); + } + + return () => { + clearTimeout(timer); + window.removeEventListener('resize', handleResize); + if (resizeObserver && terminalRef.current) { + resizeObserver.unobserve(terminalRef.current); + resizeObserver.disconnect(); + } + // 清理时直接关闭,不显示断开中状态 + if (wsRef.current) { + wsRef.current.close(1000, 'Component unmounted'); + wsRef.current = null; + } + if (terminalInstanceRef.current) { + terminalInstanceRef.current.dispose(); + terminalInstanceRef.current = null; + } + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + } + isClosingRef.current = false; + }; + }, [open, initializeTerminalAndConnect]); + + const connectWebSocket = () => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + + console.log('\ud83d\udd0c 开始建立WebSocket连接'); + setConnectionStatus('connecting'); + setErrorMessage(''); + + // 获取token + const token = localStorage.getItem('token'); + if (!token) { + const errorMsg = '认证失败:未登录或登录已过期'; + console.error('❌', errorMsg); + setConnectionStatus('error'); + setErrorMessage(errorMsg); + message.error(errorMsg); + return; + } + + // 获取WebSocket URL - 根据当前协议自动判断,并携带token + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log('\u2705 WebSocket\u8fde\u63a5\u5df2\u5efa\u7acb'); + // \u6ce8\u610f\uff1a\u8fd9\u91cc\u4e0d\u76f4\u63a5\u8bbe\u7f6e\u4e3aconnected\uff0c\u7b49\u5f85\u540e\u7aef\u53d1\u9001status:connected + terminalInstanceRef.current?.writeln('\x1b[32m✓ WebSocket连接已建立\x1b[0m'); + terminalInstanceRef.current?.writeln('\x1b[36m正在建立SSH会话...\x1b[0m\r\n'); + }; + + ws.onmessage = (event) => { + console.log('📥 收到WebSocket消息:', event.data.substring(0, 200)); + try { + const msg: WebSocketMessage = JSON.parse(event.data); + console.log('📦 解析后的消息:', msg.type, msg); + + switch (msg.type) { + case 'output': + if (msg.data) { + console.log('📝 输出数据长度:', msg.data.length); + terminalInstanceRef.current?.write(msg.data); + } + break; + + case 'error': + console.error('❌ SSH错误:', msg.message); + terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${msg.message}\x1b[0m\r\n`); + message.error(msg.message || '连接错误'); + break; + + case 'status': + console.log('🔄 状态变更:', msg.status); + if (msg.status) { + setConnectionStatus(msg.status); + if (msg.status === 'connected') { + terminalInstanceRef.current?.writeln('\x1b[32m✓ SSH会话已建立\x1b[0m\r\n'); + // 连接成功后重新fit,确保尺寸正确 + setTimeout(() => { + fitAddonRef.current?.fit(); + console.log('📐 连接成功后重新调整终端尺寸'); + }, 100); + } else if (msg.status === 'disconnected') { + terminalInstanceRef.current?.writeln('\r\n\x1b[33m连接已断开\x1b[0m'); + } + } + break; + } + } catch (error) { + console.error('❌ 解析WebSocket消息失败:', error, '原始数据:', event.data); + } + }; + + ws.onerror = (error) => { + console.error('❌ WebSocket错误:', error); + const errorMsg = 'WebSocket连接失败,请检查后端服务'; + setConnectionStatus('error'); + setErrorMessage(errorMsg); + terminalInstanceRef.current?.writeln('\r\n\x1b[31m✗ ' + errorMsg + '\x1b[0m'); + message.error(errorMsg); + }; + + ws.onclose = (event) => { + console.log('🔌 WebSocket连接已关闭, code:', event.code, 'reason:', event.reason); + wsRef.current = null; + + if (isClosingRef.current) { + // 用户主动关闭,直接关闭弹窗 + handleConnectionClosed(); + } else { + // 非主动关闭,显示断开状态 + setConnectionStatus('disconnected'); + if (!event.wasClean) { + const errorMsg = event.reason || '连接异常断开'; + setErrorMessage(errorMsg); + terminalInstanceRef.current?.writeln('\r\n\x1b[31m✗ ' + errorMsg + '\x1b[0m'); + } else { + terminalInstanceRef.current?.writeln('\r\n\x1b[33m连接已正常关闭\x1b[0m'); + } + } + }; + }; + + const disconnectWebSocket = (showDisconnecting: boolean = false) => { + if (wsRef.current) { + console.log('🔌 主动关闭WebSocket连接'); + if (showDisconnecting) { + setConnectionStatus('disconnecting'); + terminalInstanceRef.current?.writeln('\r\n\x1b[33m正在断开连接...\x1b[0m'); + } + if (wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.close(1000, 'User closed'); + } else { + // 如果连接已经断开,直接清理 + wsRef.current = null; + if (isClosingRef.current) { + handleConnectionClosed(); + } + } + } else if (isClosingRef.current) { + // 没有WebSocket连接,直接关闭 + handleConnectionClosed(); + } + }; + + const handleConnectionClosed = () => { + console.log('✅ 连接已完全关闭,关闭弹窗'); + isClosingRef.current = false; + // 延迟一点关闭,让用户看到断开完成 + setTimeout(() => { + // 关闭弹窗并重置所有状态 + setConnectionStatus('initializing'); + setErrorMessage(''); + onOpenChange(false); + }, 100); + }; + + const handleDialogClose = (open: boolean) => { + if (!open && connectionStatus !== 'disconnected') { + // 用户点击X或ESC关闭,需要先断开连接 + console.log('🚪 用户关闭弹窗,开始断开连接'); + isClosingRef.current = true; + disconnectWebSocket(true); + } else if (!open) { + // 已经断开,直接关闭 + onOpenChange(false); + } else { + // 打开弹窗 + onOpenChange(true); + } + }; + + const handleReconnect = () => { + console.log('🔄 用户重新连接'); + isClosingRef.current = false; + disconnectWebSocket(); + if (terminalInstanceRef.current) { + terminalInstanceRef.current.clear(); + terminalInstanceRef.current.dispose(); + terminalInstanceRef.current = null; + } + // 重新初始化 + initializeTerminalAndConnect(); + }; + + const getStatusBadge = () => { + switch (connectionStatus) { + case 'initializing': + return ( + + + 初始化中 + + ); + case 'connecting': + return ( + + + 连接中 + + ); + case 'connected': + return ( + +
+ 已连接 + + ); + case 'error': + return ( + + + 连接失败 + + ); + case 'disconnecting': + return ( + + + 断开中 + + ); + case 'disconnected': + return ( + + + 已断开 + + ); + } + }; + + return ( + <> + {/* 注入自定义样式 */} + + + + + +
+
+ SSH终端 + + {server.serverName} ({server.hostIp}) + +
+
+ {getStatusBadge()} + {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( + + )} +
+
+
+
+ {/* 终端容器 - 始终渲染,确保fit能正确计算尺寸 */} +
+ + {/* Loading 状态 - 覆盖在终端上方 */} + {connectionStatus === 'initializing' && ( +
+ +

初始化SSH终端...

+
+ )} + + {/* 断开中状态 - 覆盖在终端上方 */} + {connectionStatus === 'disconnecting' && ( +
+ +

正在断开连接...

+
+ )} + + {/* 错误状态 - 覆盖在终端上方 */} + {connectionStatus === 'error' && errorMessage && ( +
+ +

连接失败

+

{errorMessage}

+ +
+ )} +
+ +
+ + ); +};