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)
private Long applicationId;
/**
* 应用关联用于查询不参与保存和更新
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "application_id", insertable = false, updatable = false)
private Application application;
/**
* 环境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.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;
@ -33,5 +34,13 @@ public class TeamApplicationQuery extends BaseQuery {
@QueryField(field = "buildType")
@Schema(description = "构建类型")
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
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
public RuntimeTypeEnum supportedType() {
return RuntimeTypeEnum.DOCKER;
@ -73,9 +63,6 @@ public class DockerLogStreamStrategy implements ILogStreamStrategy {
target.getPassphrase()
);
// 保存SSH连接用于后续清理
sshClients.put(sessionId, sshClient);
// 2. 构建docker logs命令
String command = String.format("docker logs -f %s --tail %d",
target.getName(), target.getLines());
@ -84,7 +71,6 @@ public class DockerLogStreamStrategy implements ILogStreamStrategy {
// 3. 执行命令
sshSession = sshClient.startSession();
sshSessions.put(sessionId, sshSession);
Session.Command cmd = sshSession.exec(command);
@ -111,41 +97,30 @@ public class DockerLogStreamStrategy implements ILogStreamStrategy {
log.error("Docker日志流异常: sessionId={}", sessionId, e);
throw e;
} finally {
// 清理资源正常结束时
cleanupResources(sessionId, sshSession, sshClient);
// 清理SSH资源
if (sshSession != null) {
try {
sshSession.close();
log.debug("SSH Session已关闭: sessionId={}", sessionId);
} catch (Exception e) {
log.error("关闭SSH Session失败: sessionId={}", sessionId, e);
}
}
if (sshClient != null) {
try {
sshClient.disconnect();
log.debug("SSH Client已断开: sessionId={}", sessionId);
} catch (Exception e) {
log.error("断开SSH Client失败: sessionId={}", sessionId, e);
}
}
}
}
@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) {
try {
sshSession.close();
log.debug("SSH Session已关闭: sessionId={}", sessionId);
} catch (Exception e) {
log.error("关闭SSH Session失败: sessionId={}", sessionId, e);
}
}
if (sshClient != null) {
try {
sshClient.disconnect();
log.debug("SSH Client已断开: sessionId={}", sessionId);
} catch (Exception e) {
log.error("断开SSH Client失败: sessionId={}", sessionId, e);
}
}
log.info("停止Docker日志流: sessionId={}", sessionId);
// SSH连接的清理由finally块处理这里只需要日志记录
}
}

View File

@ -33,16 +33,6 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
@Resource
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
public RuntimeTypeEnum supportedType() {
return RuntimeTypeEnum.SERVER;
@ -73,9 +63,6 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
target.getPassphrase()
);
// 保存SSH连接用于后续清理
sshClients.put(sessionId, sshClient);
// 2. 构建tail命令
String command = String.format("tail -f %s -n %d",
target.getLogFilePath(), target.getLines());
@ -84,7 +71,6 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
// 3. 执行命令
sshSession = sshClient.startSession();
sshSessions.put(sessionId, sshSession);
Session.Command cmd = sshSession.exec(command);
@ -111,41 +97,30 @@ public class ServerLogStreamStrategy implements ILogStreamStrategy {
log.error("Server日志流异常: sessionId={}", sessionId, e);
throw e;
} finally {
// 清理资源正常结束时
cleanupResources(sessionId, sshSession, sshClient);
// 清理SSH资源
if (sshSession != null) {
try {
sshSession.close();
log.debug("SSH Session已关闭: sessionId={}", sessionId);
} catch (Exception e) {
log.error("关闭SSH Session失败: sessionId={}", sessionId, e);
}
}
if (sshClient != null) {
try {
sshClient.disconnect();
log.debug("SSH Client已断开: sessionId={}", sessionId);
} catch (Exception e) {
log.error("断开SSH Client失败: sessionId={}", sessionId, e);
}
}
}
}
@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) {
try {
sshSession.close();
log.debug("SSH Session已关闭: sessionId={}", sessionId);
} catch (Exception e) {
log.error("关闭SSH Session失败: sessionId={}", sessionId, e);
}
}
if (sshClient != null) {
try {
sshClient.disconnect();
log.debug("SSH Client已断开: sessionId={}", sessionId);
} catch (Exception e) {
log.error("断开SSH Client失败: sessionId={}", sessionId, e);
}
}
log.info("停止Server日志流: sessionId={}", sessionId);
// SSH连接的清理由finally块处理这里只需要日志记录
}
}

View File

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

View File

@ -212,31 +212,28 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
return;
}
// 5. 提前注册会话解决并发竞态问题
sessionManager.registerSession(sessionId, userId, target.getTargetType(), target.getMetadata());
boolean sessionRegistered = true;
// 5. 尝试注册会话原子操作包含配额检查
boolean registered = sessionManager.tryRegisterSession(
sessionId, userId, target.getTargetType(), target.getMetadata(), getMaxSessions());
if (!registered) {
long currentCount = sessionManager.countUserTotalSessions(userId);
log.warn("用户SSH连接数超过限制: userId={}, current={}, max={}",
userId, currentCount, getMaxSessions());
sendError(session, "SSH连接数已达上限(" + getMaxSessions() + "个),请关闭其他连接后重试");
session.close(CloseStatus.POLICY_VIOLATION);
return;
}
try {
// 6. 检查并发连接数注册后立即检查
long activeCount = sessionManager.countActiveSessions(userId, target.getTargetType(), target.getMetadata());
if (activeCount > getMaxSessions()) {
log.warn("用户连接数超过限制: userId={}, target={}:{}, current={}, max={}",
userId, target.getTargetType(), target.getMetadata(), activeCount, getMaxSessions());
sendError(session, "连接数超过限制(最多" + getMaxSessions() + "个)");
sessionManager.removeSession(sessionId);
sessionRegistered = false;
session.close(CloseStatus.POLICY_VIOLATION);
return;
}
// 7. 发送连接中状态
// 6. 发送连接中状态
sendStatus(session, SSHStatusEnum.CONNECTING);
// 8. 建立SSH连接
// 7. 建立SSH连接
SSHClient sshClient = createSSHConnection(target);
sshClients.put(sessionId, sshClient);
// 9. 打开Shell通道并分配PTY伪终端
// 8. 打开Shell通道并分配PTY伪终端
Session sshSession = sshClient.startSession();
// 关键分配PTY启用交互式Shell回显提示符
@ -249,7 +246,7 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
webSocketSessions.put(sessionId, session);
sshShells.put(sessionId, shell);
// 10. 优化先启动输出监听线程确保不错过任何SSH输出
// 9. 优化先启动输出监听线程确保不错过任何SSH输出
// 直接传递session和shellsessionId从session.attributes中获取
Future<?> stdoutTask = asyncTaskExecutor.submit(() -> readSSHOutput(session, shell));
outputTasks.put(sessionId, stdoutTask);
@ -258,12 +255,12 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
Future<?> stderrTask = asyncTaskExecutor.submit(() -> readSSHError(session, shell));
outputTasks.put(sessionId + "_stderr", stderrTask);
// 11. 发送连接成功状态
// 10. 发送连接成功状态
sendStatus(session, SSHStatusEnum.CONNECTED);
log.info("SSH连接成功: sessionId={}, userId={}, target={}:{}",
sessionId, userId, target.getTargetType(), target.getMetadata());
// 12. 异步创建审计日志不阻塞主线程
// 11. 异步创建审计日志不阻塞主线程
// 使用CompletableFuture异步执行避免数据库操作延迟影响SSH输出接收
java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
@ -274,10 +271,8 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
});
} catch (Exception ex) {
// 如果会话已注册需要清理
if (sessionRegistered) {
sessionManager.removeSession(sessionId);
}
// 如果连接失败需要清理已注册的会话
sessionManager.removeSession(sessionId);
throw ex; // 重新抛出由外层catch处理
}

View File

@ -72,6 +72,48 @@ public class SSHSessionManager {
.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);
public static Path<?> getPath(EntityPath<?> entityPath, String fieldName) {
// 支持点号分隔的关联路径 "application.appName"
if (fieldName.contains(".")) {
return getNestedPath(entityPath, fieldName);
}
try {
// 1. 首先尝试直接获取字段
try {
@ -53,4 +58,38 @@ public class EntityPathResolver {
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;
import com.qqchen.deploy.backend.deploy.enums.RuntimeTypeEnum;
import com.qqchen.deploy.backend.framework.enums.LogControlAction;
import com.qqchen.deploy.backend.framework.enums.LogMessageType;
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.websocket.log.request.LogControlRequest;
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.LogLineResponse;
import com.qqchen.deploy.backend.framework.websocket.log.response.LogStatusResponse;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
@ -64,6 +68,17 @@ public abstract class AbstractLogStreamWebSocketHandler extends TextWebSocketHan
*/
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;
}
// 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);
// 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);
if (task != null && !task.isDone()) {
log.debug("取消日志流任务: sessionId={}", sessionId);
task.cancel(true);
}
// 3. 移除WebSocketSession
// 4. 移除WebSocketSession
webSocketSessions.remove(sessionId);
// 4. 移除暂停标志
// 5. 移除暂停标志
pausedFlags.remove(sessionId);
// 5. 移除target信息
// 6. 移除target信息
sessionTargets.remove(sessionId);
log.info("日志流会话资源清理完成: sessionId={}", sessionId);

View File

@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Select,
SelectContent,
@ -178,50 +179,42 @@ export const LogViewerDialog: React.FC<LogViewerDialogProps> = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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">
<DialogTitle className="flex items-center justify-between">
<DialogHeader className="pl-6 pr-16 pt-6 pb-4 border-b flex-shrink-0">
<div className="flex items-center justify-between gap-4">
{/* 左侧:标题信息 */}
<div className="flex items-center gap-3">
<ScrollText className="h-5 w-5" />
<span className="text-lg font-semibold">{app.applicationName} - </span>
</div>
<div className="flex items-center gap-3 text-sm font-normal">
<span className="text-muted-foreground">|</span>
<Badge variant="outline" className={runtimeConfig.bg}>
<RuntimeIcon className={`h-3 w-3 mr-1 ${runtimeConfig.color}`} />
{runtimeConfig.label}
</Badge>
<span className="text-sm font-normal text-muted-foreground">{environment.environmentName}</span>
<Circle className={`h-2 w-2 ${statusIndicator.color} fill-current`} />
<span className="text-muted-foreground">{environment.environmentName}</span>
</div>
</DialogTitle>
</DialogHeader>
<div className="flex-1 flex flex-col overflow-hidden">
{/* 控制面板 */}
<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
id="lines"
type="number"
value={lines}
onChange={(e) => setLines(Number(e.target.value))}
min={10}
max={1000}
className="h-8 mt-1"
/>
</div>
{/* 右侧:控制元素 */}
<div className="flex items-center gap-2">
<Input
type="number"
value={lines}
onChange={(e) => setLines(Number(e.target.value))}
min={10}
max={1000}
placeholder="行数"
className="h-8 w-20"
/>
{app.runtimeType === 'K8S' && (
<div className="w-64">
<Label htmlFor="podName" className="text-xs">Pod</Label>
<>
{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" />
</div>
) : podNames.length > 0 ? (
<Select value={podName} onValueChange={setPodName}>
<SelectTrigger className="h-8 mt-1">
<SelectTrigger className="h-8 w-48">
<SelectValue placeholder="选择Pod" />
</SelectTrigger>
<SelectContent>
@ -234,82 +227,111 @@ export const LogViewerDialog: React.FC<LogViewerDialogProps> = ({
</Select>
) : (
<Input
id="podName"
value={podName}
onChange={(e) => setPodName(e.target.value)}
placeholder="无可用Pod"
className="h-8 mt-1"
className="h-8 w-48"
disabled
/>
)}
</div>
</>
)}
<div className="flex-1" />
<TooltipProvider>
<div className="flex gap-1">
{/* 启动/恢复/重试按钮 */}
{(status === LogStreamStatus.DISCONNECTED ||
status === LogStreamStatus.CONNECTED ||
status === LogStreamStatus.ERROR) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
className="h-8 w-8 p-0"
>
<Play className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{status === LogStreamStatus.ERROR ? '重试' : '启动'}
</TooltipContent>
</Tooltip>
)}
<div className="flex gap-2 items-end">
{/* 启动/恢复/重试按钮 */}
{(status === LogStreamStatus.DISCONNECTED ||
status === LogStreamStatus.CONNECTED ||
status === LogStreamStatus.ERROR) && (
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={false}
>
<Play className="h-3 w-3 mr-1" />
{status === LogStreamStatus.ERROR ? '重试' : '启动'}
</Button>
)}
{/* 连接中按钮 */}
{status === LogStreamStatus.CONNECTING && (
<Tooltip>
<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>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
)}
{/* 连接中按钮 */}
{status === LogStreamStatus.CONNECTING && (
<Button size="sm" variant="outline" disabled>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
</Button>
)}
{/* 恢复按钮(暂停状态) */}
{status === LogStreamStatus.PAUSED && (
<Tooltip>
<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>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
)}
{/* 恢复按钮(暂停状态) */}
{status === LogStreamStatus.PAUSED && (
<Button size="sm" variant="outline" onClick={resume}>
<Play className="h-3 w-3 mr-1" />
</Button>
)}
{/* 暂停按钮(流式传输中) */}
{status === LogStreamStatus.STREAMING && (
<Tooltip>
<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>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
)}
{/* 暂停按钮(流式传输中) */}
{status === LogStreamStatus.STREAMING && (
<Button size="sm" variant="outline" onClick={pause}>
<Pause className="h-3 w-3 mr-1" />
</Button>
)}
{/* 停止按钮 */}
{(status === LogStreamStatus.CONNECTING ||
status === LogStreamStatus.STREAMING ||
status === LogStreamStatus.PAUSED) && (
<Tooltip>
<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>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
)}
{/* 停止按钮 */}
{(status === LogStreamStatus.CONNECTING ||
status === LogStreamStatus.STREAMING ||
status === LogStreamStatus.PAUSED) && (
<Button size="sm" variant="outline" onClick={stop}>
<Square className="h-3 w-3 mr-1" />
</Button>
)}
{/* 清空按钮 */}
<Button
size="sm"
variant="outline"
onClick={clearLogs}
disabled={status === LogStreamStatus.CONNECTING}
>
<Trash2 className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 清空按钮 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={clearLogs}
disabled={status === LogStreamStatus.CONNECTING}
className="h-8 w-8 p-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
</div>
</DialogHeader>
<div className="flex-1 flex flex-col overflow-hidden">
{/* 日志显示区域 */}
<div className="flex-1 overflow-hidden px-6 py-4">