diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/K8sDeploymentApiController.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/K8sDeploymentApiController.java index 7bebce43..bbdd256b 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/K8sDeploymentApiController.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/K8sDeploymentApiController.java @@ -122,19 +122,23 @@ public class K8sDeploymentApiController extends BaseController getPodLogs( + public Response getPodLogs( @Parameter(description = "Deployment ID", required = true) @PathVariable Long deploymentId, @Parameter(description = "Pod名称", required = true) @PathVariable String podName, @Parameter(description = "容器名称(可选,默认第一个容器)") @RequestParam(required = false) String container, - @Parameter(description = "返回最后N行日志(可选,默认500行,最大10000行)") @RequestParam(required = false) Integer tail, - @Parameter(description = "返回最近N秒的日志(可选,默认3600秒即1小时,最大86400秒即24小时)") @RequestParam(required = false) Integer sinceSeconds + @Parameter(description = "引用点时间戳(newest/oldest/具体时间戳)", required = false) @RequestParam(required = false, defaultValue = "newest") String referenceTimestamp, + @Parameter(description = "起始偏移量(相对于引用点)", required = false) @RequestParam(required = false, defaultValue = "-100") Integer offsetFrom, + @Parameter(description = "结束偏移量(相对于引用点)", required = false) @RequestParam(required = false, defaultValue = "1") Integer offsetTo ) { - return Response.success(k8sPodService.getPodLogs(deploymentId, podName, container, tail, sinceSeconds)); + return Response.success(k8sPodService.getPodLogsWithReference(deploymentId, podName, container, referenceTimestamp, offsetFrom, offsetTo)); } @Operation(summary = "重启Deployment", description = "通过更新annotation触发Deployment滚动重启") diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sLogLine.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sLogLine.java new file mode 100644 index 00000000..e2e935a9 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sLogLine.java @@ -0,0 +1,25 @@ +package com.qqchen.deploy.backend.deploy.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * K8S Pod日志行 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class K8sLogLine { + + /** + * 日志时间戳(RFC3339格式) + * 例如:2025-12-13T23:00:01.123456789Z + */ + private String timestamp; + + /** + * 日志内容 + */ + private String content; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sLogSelection.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sLogSelection.java new file mode 100644 index 00000000..7931310d --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sLogSelection.java @@ -0,0 +1,38 @@ +package com.qqchen.deploy.backend.deploy.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * K8S Pod日志选择器 + * 基于Kubernetes Dashboard的引用点系统 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class K8sLogSelection { + + /** + * 引用点时间戳 + * 特殊值: + * - "newest": 最新的日志行 + * - "oldest": 最早的日志行 + * - RFC3339时间戳:具体的时间点 + */ + private String referenceTimestamp; + + /** + * 相对于引用点的起始偏移量(包含) + * 负数表示引用点之前的行 + * 例如:-100 表示引用点之前100行 + */ + private Integer offsetFrom; + + /** + * 相对于引用点的结束偏移量(不包含) + * 正数表示引用点之后的行 + * 例如:1 表示引用点之后1行(不包含) + */ + private Integer offsetTo; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sPodLogsResponse.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sPodLogsResponse.java new file mode 100644 index 00000000..f1f68097 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/K8sPodLogsResponse.java @@ -0,0 +1,41 @@ +package com.qqchen.deploy.backend.deploy.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * K8S Pod日志响应(引用点模式) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class K8sPodLogsResponse { + + /** + * Pod名称 + */ + private String podName; + + /** + * 容器名称 + */ + private String containerName; + + /** + * 日志选择器(用于下次请求) + */ + private K8sLogSelection selection; + + /** + * 日志行列表 + */ + private List logs; + + /** + * 是否被截断 + */ + private Boolean truncated; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/IK8sPodService.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/IK8sPodService.java index 61cc6038..55323632 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/IK8sPodService.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/IK8sPodService.java @@ -55,14 +55,17 @@ public interface IK8sPodService { List listPodsByNamespace(Long namespaceId); /** - * 查询Pod日志 + * 查询Pod日志(引用点模式) * - * @param deploymentId Deployment ID - * @param podName Pod名称 - * @param container 容器名称(可选) - * @param tail 返回最后N行日志(可选) - * @param sinceSeconds 返回最近N秒的日志(可选) - * @return Pod日志内容 + * @param deploymentId Deployment ID + * @param podName Pod名称 + * @param container 容器名称(可选) + * @param referenceTimestamp 引用点时间戳 + * @param offsetFrom 起始偏移量 + * @param offsetTo 结束偏移量 + * @return Pod日志响应 */ - String getPodLogs(Long deploymentId, String podName, String container, Integer tail, Integer sinceSeconds); + com.qqchen.deploy.backend.deploy.dto.K8sPodLogsResponse getPodLogsWithReference( + Long deploymentId, String podName, String container, + String referenceTimestamp, Integer offsetFrom, Integer offsetTo); } diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/K8sPodServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/K8sPodServiceImpl.java index a3cef767..848bd0af 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/K8sPodServiceImpl.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/K8sPodServiceImpl.java @@ -131,17 +131,12 @@ public class K8sPodServiceImpl implements IK8sPodService { } @Override - public String getPodLogs(Long deploymentId, String podName, String container, Integer tail, Integer sinceSeconds) { - log.info("查询Pod日志,deploymentId: {}, podName: {}, container: {}, tail: {}, sinceSeconds: {}", - deploymentId, podName, container, tail, sinceSeconds); - - // 参数校验 - if (tail != null && (tail < 1 || tail > 10000)) { - throw new BusinessException(ResponseCode.INVALID_PARAM, new Object[]{"tail参数范围:1-10000行"}); - } - if (sinceSeconds != null && (sinceSeconds < 1 || sinceSeconds > 86400)) { - throw new BusinessException(ResponseCode.INVALID_PARAM, new Object[]{"sinceSeconds参数范围:1-86400秒(24小时)"}); - } + public com.qqchen.deploy.backend.deploy.dto.K8sPodLogsResponse getPodLogsWithReference( + Long deploymentId, String podName, String container, + String referenceTimestamp, Integer offsetFrom, Integer offsetTo) { + + log.info("查询Pod日志(引用点模式),deploymentId: {}, podName: {}, container: {}, referenceTimestamp: {}, offsetFrom: {}, offsetTo: {}", + deploymentId, podName, container, referenceTimestamp, offsetFrom, offsetTo); // 1. 查询K8sDeployment K8sDeployment deployment = k8sDeploymentRepository.findById(deploymentId) @@ -155,15 +150,39 @@ public class K8sPodServiceImpl implements IK8sPodService { ExternalSystem externalSystem = externalSystemRepository.findById(deployment.getExternalSystemId()) .orElseThrow(() -> new BusinessException(ResponseCode.EXTERNAL_SYSTEM_NOT_FOUND)); - // 4. 调用K8s API查询Pod日志 - return k8sServiceIntegration.getPodLogs( + // 4. 调用K8s API获取原始日志(获取足够多的日志以支持切片) + // 使用较大的tail值确保能够覆盖请求的范围 + Integer effectiveTail = Math.max(1000, Math.abs(offsetFrom) + Math.abs(offsetTo) + 100); + String rawLogs = k8sServiceIntegration.getPodLogs( externalSystem, namespace.getNamespaceName(), podName, container, - tail, - sinceSeconds, - false // follow参数设为false,不持续输出 + effectiveTail, + null, // 不使用sinceSeconds,获取更多历史日志 + false + ); + + // 5. 解析日志 + List logLines = + com.qqchen.deploy.backend.deploy.utils.K8sLogParser.parseLogLines(rawLogs); + + // 6. 创建选择器 + com.qqchen.deploy.backend.deploy.dto.K8sLogSelection selection = + new com.qqchen.deploy.backend.deploy.dto.K8sLogSelection( + referenceTimestamp, offsetFrom, offsetTo); + + // 7. 切片日志 + com.qqchen.deploy.backend.deploy.utils.K8sLogParser.LogSliceResult result = + com.qqchen.deploy.backend.deploy.utils.K8sLogParser.selectLogs(logLines, selection); + + // 8. 构建响应 + return new com.qqchen.deploy.backend.deploy.dto.K8sPodLogsResponse( + podName, + container != null ? container : "default", + result.getSelection(), + result.getLogs(), + result.isTruncated() ); } } diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/utils/K8sLogParser.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/utils/K8sLogParser.java new file mode 100644 index 00000000..3403fed8 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/utils/K8sLogParser.java @@ -0,0 +1,182 @@ +package com.qqchen.deploy.backend.deploy.utils; + +import com.qqchen.deploy.backend.deploy.dto.K8sLogLine; +import com.qqchen.deploy.backend.deploy.dto.K8sLogSelection; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * K8S日志解析工具类 + * 基于Kubernetes Dashboard的引用点系统实现 + */ +@Slf4j +public class K8sLogParser { + + private static final String NEWEST = "newest"; + private static final String OLDEST = "oldest"; + + /** + * 解析K8S原始日志为日志行列表 + * K8S日志格式:2025-12-13T23:00:01.123456789Z log content here + * + * @param rawLogs 原始日志字符串 + * @return 日志行列表 + */ + public static List parseLogLines(String rawLogs) { + if (rawLogs == null || rawLogs.trim().isEmpty()) { + return Collections.emptyList(); + } + + List lines = new ArrayList<>(); + String[] logLines = rawLogs.split("\n"); + + for (String line : logLines) { + if (line.trim().isEmpty()) { + continue; + } + + // 查找第一个空格,分隔时间戳和内容 + int spaceIndex = line.indexOf(' '); + if (spaceIndex > 0 && spaceIndex < line.length() - 1) { + String timestamp = line.substring(0, spaceIndex); + String content = line.substring(spaceIndex + 1); + lines.add(new K8sLogLine(timestamp, content)); + } else { + // 如果没有时间戳,使用空字符串作为时间戳 + lines.add(new K8sLogLine("", line)); + } + } + + return lines; + } + + /** + * 查找引用点在日志列表中的索引 + * + * @param lines 日志行列表 + * @param referenceTimestamp 引用点时间戳 + * @return 引用点索引,如果未找到返回-1 + */ + public static int findReferenceIndex(List lines, String referenceTimestamp) { + if (lines == null || lines.isEmpty()) { + return -1; + } + + // 特殊值处理 + if (NEWEST.equals(referenceTimestamp)) { + return lines.size() - 1; + } + if (OLDEST.equals(referenceTimestamp)) { + return 0; + } + + // 使用二分查找定位时间戳 + int index = Collections.binarySearch(lines, new K8sLogLine(referenceTimestamp, ""), + (a, b) -> a.getTimestamp().compareTo(b.getTimestamp())); + + if (index >= 0) { + // 找到精确匹配 + return index; + } else { + // 未找到精确匹配,返回插入点 + int insertionPoint = -(index + 1); + // 返回最接近的索引 + if (insertionPoint >= lines.size()) { + return lines.size() - 1; + } + if (insertionPoint <= 0) { + return 0; + } + return insertionPoint; + } + } + + /** + * 根据选择器切片日志 + * + * @param lines 日志行列表 + * @param selection 日志选择器 + * @return 切片后的日志行列表和新的选择器 + */ + public static LogSliceResult selectLogs(List lines, K8sLogSelection selection) { + if (lines == null || lines.isEmpty()) { + return new LogSliceResult(Collections.emptyList(), selection, false); + } + + // 查找引用点索引 + int referenceIndex = findReferenceIndex(lines, selection.getReferenceTimestamp()); + if (referenceIndex == -1) { + log.warn("引用点未找到: {}", selection.getReferenceTimestamp()); + return new LogSliceResult(Collections.emptyList(), selection, false); + } + + // 计算切片范围 + int fromIndex = referenceIndex + selection.getOffsetFrom(); + int toIndex = referenceIndex + selection.getOffsetTo(); + + // 边界处理 + boolean truncated = false; + if (toIndex > lines.size()) { + fromIndex -= (toIndex - lines.size()); + toIndex = lines.size(); + truncated = true; + } + if (fromIndex < 0) { + toIndex += -fromIndex; + fromIndex = 0; + truncated = true; + } + if (toIndex > lines.size()) { + toIndex = lines.size(); + } + + // 确保范围有效 + if (fromIndex >= toIndex || fromIndex < 0 || toIndex > lines.size()) { + log.warn("无效的切片范围: fromIndex={}, toIndex={}, size={}", fromIndex, toIndex, lines.size()); + return new LogSliceResult(Collections.emptyList(), selection, false); + } + + // 切片 + List result = lines.subList(fromIndex, toIndex); + + // 创建新的引用点(使用中间行) + int middleIndex = lines.size() / 2; + K8sLogSelection newSelection = new K8sLogSelection( + lines.get(middleIndex).getTimestamp(), + fromIndex - middleIndex, + toIndex - middleIndex + ); + + return new LogSliceResult(new ArrayList<>(result), newSelection, truncated); + } + + /** + * 日志切片结果 + */ + public static class LogSliceResult { + private final List logs; + private final K8sLogSelection selection; + private final boolean truncated; + + public LogSliceResult(List logs, K8sLogSelection selection, boolean truncated) { + this.logs = logs; + this.selection = selection; + this.truncated = truncated; + } + + public List getLogs() { + return logs; + } + + public K8sLogSelection getSelection() { + return selection; + } + + public boolean isTruncated() { + return truncated; + } + } +}