1.33 日志通用查询
This commit is contained in:
parent
5a7970da36
commit
82eb9ca6d6
@ -32,6 +32,13 @@ public class TeamApplication extends Entity<Long> {
|
|||||||
@Column(name = "application_id", nullable = false)
|
@Column(name = "application_id", nullable = false)
|
||||||
private Long applicationId;
|
private Long applicationId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用关联(用于查询,不参与保存和更新)
|
||||||
|
*/
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "application_id", insertable = false, updatable = false)
|
||||||
|
private Application application;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 环境ID
|
* 环境ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.qqchen.deploy.backend.deploy.query;
|
|||||||
|
|
||||||
import com.qqchen.deploy.backend.deploy.enums.BuildTypeEnum;
|
import com.qqchen.deploy.backend.deploy.enums.BuildTypeEnum;
|
||||||
import com.qqchen.deploy.backend.framework.annotation.QueryField;
|
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 com.qqchen.deploy.backend.framework.query.BaseQuery;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@ -33,5 +34,13 @@ public class TeamApplicationQuery extends BaseQuery {
|
|||||||
@QueryField(field = "buildType")
|
@QueryField(field = "buildType")
|
||||||
@Schema(description = "构建类型")
|
@Schema(description = "构建类型")
|
||||||
private BuildTypeEnum buildType;
|
private BuildTypeEnum buildType;
|
||||||
|
|
||||||
|
@QueryField(field = "application.appName", type = QueryType.LIKE)
|
||||||
|
@Schema(description = "应用名称(模糊查询)")
|
||||||
|
private String applicationName;
|
||||||
|
|
||||||
|
@QueryField(field = "application.appCode", type = QueryType.LIKE)
|
||||||
|
@Schema(description = "应用编码(模糊查询)")
|
||||||
|
private String applicationCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,16 +33,6 @@ public class DockerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
@Resource
|
@Resource
|
||||||
private SSHCommandServiceFactory sshCommandServiceFactory;
|
private SSHCommandServiceFactory sshCommandServiceFactory;
|
||||||
|
|
||||||
/**
|
|
||||||
* SSH连接存储:sessionId → SSHClient
|
|
||||||
*/
|
|
||||||
private final Map<String, SSHClient> sshClients = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SSH会话存储:sessionId → Session
|
|
||||||
*/
|
|
||||||
private final Map<String, Session> sshSessions = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RuntimeTypeEnum supportedType() {
|
public RuntimeTypeEnum supportedType() {
|
||||||
return RuntimeTypeEnum.DOCKER;
|
return RuntimeTypeEnum.DOCKER;
|
||||||
@ -73,9 +63,6 @@ public class DockerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
target.getPassphrase()
|
target.getPassphrase()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 保存SSH连接,用于后续清理
|
|
||||||
sshClients.put(sessionId, sshClient);
|
|
||||||
|
|
||||||
// 2. 构建docker logs命令
|
// 2. 构建docker logs命令
|
||||||
String command = String.format("docker logs -f %s --tail %d",
|
String command = String.format("docker logs -f %s --tail %d",
|
||||||
target.getName(), target.getLines());
|
target.getName(), target.getLines());
|
||||||
@ -84,7 +71,6 @@ public class DockerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
|
|
||||||
// 3. 执行命令
|
// 3. 执行命令
|
||||||
sshSession = sshClient.startSession();
|
sshSession = sshClient.startSession();
|
||||||
sshSessions.put(sessionId, sshSession);
|
|
||||||
|
|
||||||
Session.Command cmd = sshSession.exec(command);
|
Session.Command cmd = sshSession.exec(command);
|
||||||
|
|
||||||
@ -111,25 +97,7 @@ public class DockerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
log.error("Docker日志流异常: sessionId={}", sessionId, e);
|
log.error("Docker日志流异常: sessionId={}", sessionId, e);
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
// 清理资源(正常结束时)
|
// 清理SSH资源
|
||||||
cleanupResources(sessionId, sshSession, sshClient);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void stop(String sessionId) {
|
|
||||||
log.info("停止Docker日志流并清理资源: sessionId={}", sessionId);
|
|
||||||
|
|
||||||
Session sshSession = sshSessions.remove(sessionId);
|
|
||||||
SSHClient sshClient = sshClients.remove(sessionId);
|
|
||||||
|
|
||||||
cleanupResources(sessionId, sshSession, sshClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理SSH资源
|
|
||||||
*/
|
|
||||||
private void cleanupResources(String sessionId, Session sshSession, SSHClient sshClient) {
|
|
||||||
if (sshSession != null) {
|
if (sshSession != null) {
|
||||||
try {
|
try {
|
||||||
sshSession.close();
|
sshSession.close();
|
||||||
@ -149,3 +117,10 @@ public class DockerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop(String sessionId) {
|
||||||
|
log.info("停止Docker日志流: sessionId={}", sessionId);
|
||||||
|
// SSH连接的清理由finally块处理,这里只需要日志记录
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -33,16 +33,6 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
@Resource
|
@Resource
|
||||||
private SSHCommandServiceFactory sshCommandServiceFactory;
|
private SSHCommandServiceFactory sshCommandServiceFactory;
|
||||||
|
|
||||||
/**
|
|
||||||
* SSH连接存储:sessionId → SSHClient
|
|
||||||
*/
|
|
||||||
private final Map<String, SSHClient> sshClients = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SSH会话存储:sessionId → Session
|
|
||||||
*/
|
|
||||||
private final Map<String, Session> sshSessions = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RuntimeTypeEnum supportedType() {
|
public RuntimeTypeEnum supportedType() {
|
||||||
return RuntimeTypeEnum.SERVER;
|
return RuntimeTypeEnum.SERVER;
|
||||||
@ -73,9 +63,6 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
target.getPassphrase()
|
target.getPassphrase()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 保存SSH连接,用于后续清理
|
|
||||||
sshClients.put(sessionId, sshClient);
|
|
||||||
|
|
||||||
// 2. 构建tail命令
|
// 2. 构建tail命令
|
||||||
String command = String.format("tail -f %s -n %d",
|
String command = String.format("tail -f %s -n %d",
|
||||||
target.getLogFilePath(), target.getLines());
|
target.getLogFilePath(), target.getLines());
|
||||||
@ -84,7 +71,6 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
|
|
||||||
// 3. 执行命令
|
// 3. 执行命令
|
||||||
sshSession = sshClient.startSession();
|
sshSession = sshClient.startSession();
|
||||||
sshSessions.put(sessionId, sshSession);
|
|
||||||
|
|
||||||
Session.Command cmd = sshSession.exec(command);
|
Session.Command cmd = sshSession.exec(command);
|
||||||
|
|
||||||
@ -111,25 +97,7 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
log.error("Server日志流异常: sessionId={}", sessionId, e);
|
log.error("Server日志流异常: sessionId={}", sessionId, e);
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
// 清理资源(正常结束时)
|
// 清理SSH资源
|
||||||
cleanupResources(sessionId, sshSession, sshClient);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void stop(String sessionId) {
|
|
||||||
log.info("停止Server日志流并清理资源: sessionId={}", sessionId);
|
|
||||||
|
|
||||||
Session sshSession = sshSessions.remove(sessionId);
|
|
||||||
SSHClient sshClient = sshClients.remove(sessionId);
|
|
||||||
|
|
||||||
cleanupResources(sessionId, sshSession, sshClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理SSH资源
|
|
||||||
*/
|
|
||||||
private void cleanupResources(String sessionId, Session sshSession, SSHClient sshClient) {
|
|
||||||
if (sshSession != null) {
|
if (sshSession != null) {
|
||||||
try {
|
try {
|
||||||
sshSession.close();
|
sshSession.close();
|
||||||
@ -149,3 +117,10 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop(String sessionId) {
|
||||||
|
log.info("停止Server日志流: sessionId={}", sessionId);
|
||||||
|
// SSH连接的清理由finally块处理,这里只需要日志记录
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -19,6 +19,11 @@ public enum SSHTargetType {
|
|||||||
*/
|
*/
|
||||||
CONTAINER,
|
CONTAINER,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志流(用于日志查看功能的SSH连接)
|
||||||
|
*/
|
||||||
|
LOG_STREAM,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 其他自定义类型
|
* 其他自定义类型
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -212,31 +212,28 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 提前注册会话(解决并发竞态问题)
|
// 5. 尝试注册会话(原子操作,包含配额检查)
|
||||||
sessionManager.registerSession(sessionId, userId, target.getTargetType(), target.getMetadata());
|
boolean registered = sessionManager.tryRegisterSession(
|
||||||
boolean sessionRegistered = true;
|
sessionId, userId, target.getTargetType(), target.getMetadata(), getMaxSessions());
|
||||||
|
|
||||||
try {
|
if (!registered) {
|
||||||
// 6. 检查并发连接数(注册后立即检查)
|
long currentCount = sessionManager.countUserTotalSessions(userId);
|
||||||
long activeCount = sessionManager.countActiveSessions(userId, target.getTargetType(), target.getMetadata());
|
log.warn("用户SSH连接数超过限制: userId={}, current={}, max={}",
|
||||||
if (activeCount > getMaxSessions()) {
|
userId, currentCount, getMaxSessions());
|
||||||
log.warn("用户连接数超过限制: userId={}, target={}:{}, current={}, max={}",
|
sendError(session, "SSH连接数已达上限(" + getMaxSessions() + "个),请关闭其他连接后重试");
|
||||||
userId, target.getTargetType(), target.getMetadata(), activeCount, getMaxSessions());
|
|
||||||
sendError(session, "连接数超过限制(最多" + getMaxSessions() + "个)");
|
|
||||||
sessionManager.removeSession(sessionId);
|
|
||||||
sessionRegistered = false;
|
|
||||||
session.close(CloseStatus.POLICY_VIOLATION);
|
session.close(CloseStatus.POLICY_VIOLATION);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 发送连接中状态
|
try {
|
||||||
|
// 6. 发送连接中状态
|
||||||
sendStatus(session, SSHStatusEnum.CONNECTING);
|
sendStatus(session, SSHStatusEnum.CONNECTING);
|
||||||
|
|
||||||
// 8. 建立SSH连接
|
// 7. 建立SSH连接
|
||||||
SSHClient sshClient = createSSHConnection(target);
|
SSHClient sshClient = createSSHConnection(target);
|
||||||
sshClients.put(sessionId, sshClient);
|
sshClients.put(sessionId, sshClient);
|
||||||
|
|
||||||
// 9. 打开Shell通道并分配PTY(伪终端)
|
// 8. 打开Shell通道并分配PTY(伪终端)
|
||||||
Session sshSession = sshClient.startSession();
|
Session sshSession = sshClient.startSession();
|
||||||
|
|
||||||
// ⚠️ 关键:分配PTY,启用交互式Shell、回显、提示符
|
// ⚠️ 关键:分配PTY,启用交互式Shell、回显、提示符
|
||||||
@ -249,7 +246,7 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
|
|||||||
webSocketSessions.put(sessionId, session);
|
webSocketSessions.put(sessionId, session);
|
||||||
sshShells.put(sessionId, shell);
|
sshShells.put(sessionId, shell);
|
||||||
|
|
||||||
// 10. ⚠️ 优化:先启动输出监听线程,确保不错过任何SSH输出
|
// 9. ⚠️ 优化:先启动输出监听线程,确保不错过任何SSH输出
|
||||||
// 直接传递session和shell,sessionId从session.attributes中获取
|
// 直接传递session和shell,sessionId从session.attributes中获取
|
||||||
Future<?> stdoutTask = asyncTaskExecutor.submit(() -> readSSHOutput(session, shell));
|
Future<?> stdoutTask = asyncTaskExecutor.submit(() -> readSSHOutput(session, shell));
|
||||||
outputTasks.put(sessionId, stdoutTask);
|
outputTasks.put(sessionId, stdoutTask);
|
||||||
@ -258,12 +255,12 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
|
|||||||
Future<?> stderrTask = asyncTaskExecutor.submit(() -> readSSHError(session, shell));
|
Future<?> stderrTask = asyncTaskExecutor.submit(() -> readSSHError(session, shell));
|
||||||
outputTasks.put(sessionId + "_stderr", stderrTask);
|
outputTasks.put(sessionId + "_stderr", stderrTask);
|
||||||
|
|
||||||
// 11. 发送连接成功状态
|
// 10. 发送连接成功状态
|
||||||
sendStatus(session, SSHStatusEnum.CONNECTED);
|
sendStatus(session, SSHStatusEnum.CONNECTED);
|
||||||
log.info("SSH连接成功: sessionId={}, userId={}, target={}:{}",
|
log.info("SSH连接成功: sessionId={}, userId={}, target={}:{}",
|
||||||
sessionId, userId, target.getTargetType(), target.getMetadata());
|
sessionId, userId, target.getTargetType(), target.getMetadata());
|
||||||
|
|
||||||
// 12. ⚠️ 异步创建审计日志,不阻塞主线程
|
// 11. ⚠️ 异步创建审计日志,不阻塞主线程
|
||||||
// 使用CompletableFuture异步执行,避免数据库操作延迟影响SSH输出接收
|
// 使用CompletableFuture异步执行,避免数据库操作延迟影响SSH输出接收
|
||||||
java.util.concurrent.CompletableFuture.runAsync(() -> {
|
java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||||
try {
|
try {
|
||||||
@ -274,10 +271,8 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
// 如果会话已注册,需要清理
|
// 如果连接失败,需要清理已注册的会话
|
||||||
if (sessionRegistered) {
|
|
||||||
sessionManager.removeSession(sessionId);
|
sessionManager.removeSession(sessionId);
|
||||||
}
|
|
||||||
throw ex; // 重新抛出,由外层catch处理
|
throw ex; // 重新抛出,由外层catch处理
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,48 @@ public class SSHSessionManager {
|
|||||||
.count();
|
.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户的总SSH连接数(所有目标)
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 用户的总活跃会话数
|
||||||
|
*/
|
||||||
|
public long countUserTotalSessions(Long userId) {
|
||||||
|
return sessions.values().stream()
|
||||||
|
.filter(s -> s.getUserId().equals(userId))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试注册会话(原子操作,包含配额检查)
|
||||||
|
*
|
||||||
|
* @param sessionId WebSocket会话ID
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param targetType 目标类型
|
||||||
|
* @param targetId 目标ID
|
||||||
|
* @param maxSessions 最大连接数
|
||||||
|
* @return 是否注册成功(false表示超过配额)
|
||||||
|
*/
|
||||||
|
public synchronized boolean tryRegisterSession(String sessionId, Long userId,
|
||||||
|
SSHTargetType targetType, Object targetId,
|
||||||
|
int maxSessions) {
|
||||||
|
// 1. 统计用户当前总连接数
|
||||||
|
long currentCount = countUserTotalSessions(userId);
|
||||||
|
|
||||||
|
// 2. 检查是否超过限制
|
||||||
|
if (currentCount >= maxSessions) {
|
||||||
|
log.warn("用户SSH连接数已达上限: userId={}, current={}, max={}",
|
||||||
|
userId, currentCount, maxSessions);
|
||||||
|
return false; // 超过配额,拒绝注册
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 注册会话
|
||||||
|
registerSession(sessionId, userId, targetType, targetId);
|
||||||
|
log.debug("会话注册成功: sessionId={}, userId={}, current={}/{}",
|
||||||
|
sessionId, userId, currentCount + 1, maxSessions);
|
||||||
|
return true; // 注册成功
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取会话信息
|
* 获取会话信息
|
||||||
*
|
*
|
||||||
|
|||||||
@ -11,6 +11,11 @@ public class EntityPathResolver {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(EntityPathResolver.class);
|
private static final Logger log = LoggerFactory.getLogger(EntityPathResolver.class);
|
||||||
|
|
||||||
public static Path<?> getPath(EntityPath<?> entityPath, String fieldName) {
|
public static Path<?> getPath(EntityPath<?> entityPath, String fieldName) {
|
||||||
|
// 支持点号分隔的关联路径(如 "application.appName")
|
||||||
|
if (fieldName.contains(".")) {
|
||||||
|
return getNestedPath(entityPath, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 首先尝试直接获取字段
|
// 1. 首先尝试直接获取字段
|
||||||
try {
|
try {
|
||||||
@ -53,4 +58,38 @@ public class EntityPathResolver {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析嵌套路径(支持点号分隔,如 "application.appName")
|
||||||
|
*/
|
||||||
|
private static Path<?> getNestedPath(EntityPath<?> entityPath, String fieldName) {
|
||||||
|
String[] parts = fieldName.split("\\.", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String firstField = parts[0];
|
||||||
|
String remainingPath = parts[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取第一级字段(如 "application")
|
||||||
|
Path<?> firstPath = getPath(entityPath, firstField);
|
||||||
|
if (firstPath == null) {
|
||||||
|
log.debug("First level field {} not found", firstField);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果第一级字段不是 EntityPath,无法继续解析
|
||||||
|
if (!(firstPath instanceof EntityPath)) {
|
||||||
|
log.debug("Field {} is not an EntityPath, cannot resolve nested path", firstField);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归解析剩余路径
|
||||||
|
return getPath((EntityPath<?>) firstPath, remainingPath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error resolving nested path {}: {}", fieldName, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,14 +1,18 @@
|
|||||||
package com.qqchen.deploy.backend.framework.websocket.log;
|
package com.qqchen.deploy.backend.framework.websocket.log;
|
||||||
|
|
||||||
|
import com.qqchen.deploy.backend.deploy.enums.RuntimeTypeEnum;
|
||||||
import com.qqchen.deploy.backend.framework.enums.LogControlAction;
|
import com.qqchen.deploy.backend.framework.enums.LogControlAction;
|
||||||
import com.qqchen.deploy.backend.framework.enums.LogMessageType;
|
import com.qqchen.deploy.backend.framework.enums.LogMessageType;
|
||||||
import com.qqchen.deploy.backend.framework.enums.LogStatusEnum;
|
import com.qqchen.deploy.backend.framework.enums.LogStatusEnum;
|
||||||
|
import com.qqchen.deploy.backend.framework.enums.SSHTargetType;
|
||||||
|
import com.qqchen.deploy.backend.framework.ssh.websocket.SSHSessionManager;
|
||||||
import com.qqchen.deploy.backend.framework.utils.JsonUtils;
|
import com.qqchen.deploy.backend.framework.utils.JsonUtils;
|
||||||
import com.qqchen.deploy.backend.framework.websocket.log.request.LogControlRequest;
|
import com.qqchen.deploy.backend.framework.websocket.log.request.LogControlRequest;
|
||||||
import com.qqchen.deploy.backend.framework.websocket.log.request.LogStreamRequest;
|
import com.qqchen.deploy.backend.framework.websocket.log.request.LogStreamRequest;
|
||||||
import com.qqchen.deploy.backend.framework.websocket.log.response.LogErrorResponse;
|
import com.qqchen.deploy.backend.framework.websocket.log.response.LogErrorResponse;
|
||||||
import com.qqchen.deploy.backend.framework.websocket.log.response.LogLineResponse;
|
import com.qqchen.deploy.backend.framework.websocket.log.response.LogLineResponse;
|
||||||
import com.qqchen.deploy.backend.framework.websocket.log.response.LogStatusResponse;
|
import com.qqchen.deploy.backend.framework.websocket.log.response.LogStatusResponse;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.socket.CloseStatus;
|
import org.springframework.web.socket.CloseStatus;
|
||||||
import org.springframework.web.socket.TextMessage;
|
import org.springframework.web.socket.TextMessage;
|
||||||
@ -64,6 +68,17 @@ public abstract class AbstractLogStreamWebSocketHandler extends TextWebSocketHan
|
|||||||
*/
|
*/
|
||||||
protected final Map<String, ILogStreamStrategy> sessionStrategies = new ConcurrentHashMap<>();
|
protected final Map<String, ILogStreamStrategy> sessionStrategies = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH会话管理器(用于配额管理)
|
||||||
|
*/
|
||||||
|
@Resource
|
||||||
|
private SSHSessionManager sshSessionManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最大SSH连接数(与SSH终端共享配额)
|
||||||
|
*/
|
||||||
|
private static final int MAX_SSH_SESSIONS = 5;
|
||||||
|
|
||||||
// ========== 子类必须实现的抽象方法 ==========
|
// ========== 子类必须实现的抽象方法 ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -194,7 +209,28 @@ public abstract class AbstractLogStreamWebSocketHandler extends TextWebSocketHan
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 发送流式传输状态
|
// 5. 对于需要SSH的运行时类型,检查配额
|
||||||
|
if (target.getRuntimeType() == RuntimeTypeEnum.SERVER
|
||||||
|
|| target.getRuntimeType() == RuntimeTypeEnum.DOCKER) {
|
||||||
|
|
||||||
|
// 尝试注册会话(包含配额检查)
|
||||||
|
boolean registered = sshSessionManager.tryRegisterSession(
|
||||||
|
sessionId, userId,
|
||||||
|
SSHTargetType.LOG_STREAM,
|
||||||
|
target.getName(), MAX_SSH_SESSIONS);
|
||||||
|
|
||||||
|
if (!registered) {
|
||||||
|
long currentCount = sshSessionManager.countUserTotalSessions(userId);
|
||||||
|
log.warn("用户SSH连接数超过限制: userId={}, current={}, max={}",
|
||||||
|
userId, currentCount, MAX_SSH_SESSIONS);
|
||||||
|
sendError(session, "SSH连接数已达上限(" + MAX_SSH_SESSIONS + "个),请关闭其他连接后重试");
|
||||||
|
return; // 不启动日志流
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("日志流SSH会话注册成功: sessionId={}, userId={}", sessionId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 发送流式传输状态
|
||||||
sendStatus(session, LogStatusEnum.STREAMING);
|
sendStatus(session, LogStatusEnum.STREAMING);
|
||||||
|
|
||||||
// 6. 创建暂停标志
|
// 6. 创建暂停标志
|
||||||
@ -331,20 +367,28 @@ public abstract class AbstractLogStreamWebSocketHandler extends TextWebSocketHan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 取消日志流任务
|
// 2. 从SSH会话管理器移除(释放配额)
|
||||||
|
try {
|
||||||
|
sshSessionManager.removeSession(sessionId);
|
||||||
|
log.debug("SSH会话已从配额管理器移除: sessionId={}", sessionId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("从SSH会话管理器移除失败: sessionId={}", sessionId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 取消日志流任务
|
||||||
Future<?> task = streamTasks.remove(sessionId);
|
Future<?> task = streamTasks.remove(sessionId);
|
||||||
if (task != null && !task.isDone()) {
|
if (task != null && !task.isDone()) {
|
||||||
log.debug("取消日志流任务: sessionId={}", sessionId);
|
log.debug("取消日志流任务: sessionId={}", sessionId);
|
||||||
task.cancel(true);
|
task.cancel(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 移除WebSocketSession
|
// 4. 移除WebSocketSession
|
||||||
webSocketSessions.remove(sessionId);
|
webSocketSessions.remove(sessionId);
|
||||||
|
|
||||||
// 4. 移除暂停标志
|
// 5. 移除暂停标志
|
||||||
pausedFlags.remove(sessionId);
|
pausedFlags.remove(sessionId);
|
||||||
|
|
||||||
// 5. 移除target信息
|
// 6. 移除target信息
|
||||||
sessionTargets.remove(sessionId);
|
sessionTargets.remove(sessionId);
|
||||||
|
|
||||||
log.info("日志流会话资源清理完成: sessionId={}", sessionId);
|
log.info("日志流会话资源清理完成: sessionId={}", sessionId);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -178,50 +179,42 @@ export const LogViewerDialog: React.FC<LogViewerDialogProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-6xl h-[85vh] flex flex-col p-0 overflow-hidden">
|
<DialogContent className="max-w-6xl h-[85vh] flex flex-col p-0 overflow-hidden">
|
||||||
<DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
|
<DialogHeader className="pl-6 pr-16 pt-6 pb-4 border-b flex-shrink-0">
|
||||||
<DialogTitle className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{/* 左侧:标题信息 */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ScrollText className="h-5 w-5" />
|
<ScrollText className="h-5 w-5" />
|
||||||
<span className="text-lg font-semibold">{app.applicationName} - 日志查看</span>
|
<span className="text-lg font-semibold">{app.applicationName} - 日志查看</span>
|
||||||
</div>
|
<span className="text-muted-foreground">|</span>
|
||||||
<div className="flex items-center gap-3 text-sm font-normal">
|
|
||||||
<Badge variant="outline" className={runtimeConfig.bg}>
|
<Badge variant="outline" className={runtimeConfig.bg}>
|
||||||
<RuntimeIcon className={`h-3 w-3 mr-1 ${runtimeConfig.color}`} />
|
<RuntimeIcon className={`h-3 w-3 mr-1 ${runtimeConfig.color}`} />
|
||||||
{runtimeConfig.label}
|
{runtimeConfig.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">{environment.environmentName}</span>
|
||||||
<Circle className={`h-2 w-2 ${statusIndicator.color} fill-current`} />
|
<Circle className={`h-2 w-2 ${statusIndicator.color} fill-current`} />
|
||||||
<span className="text-muted-foreground">{environment.environmentName}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
{/* 右侧:控制元素 */}
|
||||||
{/* 控制面板 */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="px-6 py-3 border-b flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-24">
|
|
||||||
<Label htmlFor="lines" className="text-xs">行数</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="lines"
|
|
||||||
type="number"
|
type="number"
|
||||||
value={lines}
|
value={lines}
|
||||||
onChange={(e) => setLines(Number(e.target.value))}
|
onChange={(e) => setLines(Number(e.target.value))}
|
||||||
min={10}
|
min={10}
|
||||||
max={1000}
|
max={1000}
|
||||||
className="h-8 mt-1"
|
placeholder="行数"
|
||||||
|
className="h-8 w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{app.runtimeType === 'K8S' && (
|
{app.runtimeType === 'K8S' && (
|
||||||
<div className="w-64">
|
<>
|
||||||
<Label htmlFor="podName" className="text-xs">Pod</Label>
|
|
||||||
{loadingPods ? (
|
{loadingPods ? (
|
||||||
<div className="h-8 flex items-center justify-center border rounded-md mt-1">
|
<div className="h-8 w-48 flex items-center justify-center border rounded-md">
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : podNames.length > 0 ? (
|
) : podNames.length > 0 ? (
|
||||||
<Select value={podName} onValueChange={setPodName}>
|
<Select value={podName} onValueChange={setPodName}>
|
||||||
<SelectTrigger className="h-8 mt-1">
|
<SelectTrigger className="h-8 w-48">
|
||||||
<SelectValue placeholder="选择Pod" />
|
<SelectValue placeholder="选择Pod" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -234,82 +227,111 @@ export const LogViewerDialog: React.FC<LogViewerDialogProps> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id="podName"
|
|
||||||
value={podName}
|
value={podName}
|
||||||
onChange={(e) => setPodName(e.target.value)}
|
onChange={(e) => setPodName(e.target.value)}
|
||||||
placeholder="无可用Pod"
|
placeholder="无可用Pod"
|
||||||
className="h-8 mt-1"
|
className="h-8 w-48"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<TooltipProvider>
|
||||||
|
<div className="flex gap-1">
|
||||||
<div className="flex gap-2 items-end">
|
|
||||||
{/* 启动/恢复/重试按钮 */}
|
{/* 启动/恢复/重试按钮 */}
|
||||||
{(status === LogStreamStatus.DISCONNECTED ||
|
{(status === LogStreamStatus.DISCONNECTED ||
|
||||||
status === LogStreamStatus.CONNECTED ||
|
status === LogStreamStatus.CONNECTED ||
|
||||||
status === LogStreamStatus.ERROR) && (
|
status === LogStreamStatus.ERROR) && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleRestart}
|
onClick={handleRestart}
|
||||||
disabled={false}
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<Play className="h-3 w-3 mr-1" />
|
<Play className="h-3.5 w-3.5" />
|
||||||
{status === LogStreamStatus.ERROR ? '重试' : '启动'}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{status === LogStreamStatus.ERROR ? '重试' : '启动'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 连接中按钮 */}
|
{/* 连接中按钮 */}
|
||||||
{status === LogStreamStatus.CONNECTING && (
|
{status === LogStreamStatus.CONNECTING && (
|
||||||
<Button size="sm" variant="outline" disabled>
|
<Tooltip>
|
||||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
<TooltipTrigger asChild>
|
||||||
连接中
|
<Button size="sm" variant="outline" disabled className="h-8 w-8 p-0">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>连接中</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 恢复按钮(暂停状态) */}
|
{/* 恢复按钮(暂停状态) */}
|
||||||
{status === LogStreamStatus.PAUSED && (
|
{status === LogStreamStatus.PAUSED && (
|
||||||
<Button size="sm" variant="outline" onClick={resume}>
|
<Tooltip>
|
||||||
<Play className="h-3 w-3 mr-1" />
|
<TooltipTrigger asChild>
|
||||||
恢复
|
<Button size="sm" variant="outline" onClick={resume} className="h-8 w-8 p-0">
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>恢复</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 暂停按钮(流式传输中) */}
|
{/* 暂停按钮(流式传输中) */}
|
||||||
{status === LogStreamStatus.STREAMING && (
|
{status === LogStreamStatus.STREAMING && (
|
||||||
<Button size="sm" variant="outline" onClick={pause}>
|
<Tooltip>
|
||||||
<Pause className="h-3 w-3 mr-1" />
|
<TooltipTrigger asChild>
|
||||||
暂停
|
<Button size="sm" variant="outline" onClick={pause} className="h-8 w-8 p-0">
|
||||||
|
<Pause className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>暂停</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 停止按钮 */}
|
{/* 停止按钮 */}
|
||||||
{(status === LogStreamStatus.CONNECTING ||
|
{(status === LogStreamStatus.CONNECTING ||
|
||||||
status === LogStreamStatus.STREAMING ||
|
status === LogStreamStatus.STREAMING ||
|
||||||
status === LogStreamStatus.PAUSED) && (
|
status === LogStreamStatus.PAUSED) && (
|
||||||
<Button size="sm" variant="outline" onClick={stop}>
|
<Tooltip>
|
||||||
<Square className="h-3 w-3 mr-1" />
|
<TooltipTrigger asChild>
|
||||||
停止
|
<Button size="sm" variant="outline" onClick={stop} className="h-8 w-8 p-0">
|
||||||
|
<Square className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>停止</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 清空按钮 */}
|
{/* 清空按钮 */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={clearLogs}
|
onClick={clearLogs}
|
||||||
disabled={status === LogStreamStatus.CONNECTING}
|
disabled={status === LogStreamStatus.CONNECTING}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 mr-1" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
清空
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>清空</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
{/* 日志显示区域 */}
|
{/* 日志显示区域 */}
|
||||||
<div className="flex-1 overflow-hidden px-6 py-4">
|
<div className="flex-1 overflow-hidden px-6 py-4">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user