1.33 日志通用查询

This commit is contained in:
dengqichen 2025-12-16 17:55:13 +08:00
parent 5a7970da36
commit 82eb9ca6d6
10 changed files with 321 additions and 208 deletions

View File

@ -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
*/ */

View File

@ -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;
} }

View File

@ -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块处理这里只需要日志记录
}
}

View File

@ -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块处理这里只需要日志记录
}
}

View File

@ -19,6 +19,11 @@ public enum SSHTargetType {
*/ */
CONTAINER, CONTAINER,
/**
* 日志流用于日志查看功能的SSH连接
*/
LOG_STREAM,
/** /**
* 其他自定义类型 * 其他自定义类型
*/ */

View File

@ -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和shellsessionId从session.attributes中获取 // 直接传递session和shellsessionId从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处理
} }

View File

@ -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; // 注册成功
}
/** /**
* 获取会话信息 * 获取会话信息
* *

View File

@ -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;
}
}
} }

View File

@ -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);

View File

@ -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">