提交 d159860a authored 作者: 陈泽健's avatar 陈泽健

feat(ui-exec-monitor): UI执行监控目标服务器Java服务日志采集与前端可视化(会话61)

后端采集链路(TargetLogMonitor):
- 新增 executors/target_log_monitor.py:SSH 单条复合命令扫描 8 个 Java 服务日志,
  tail 500 行 + 用例时间窗口 grep 夹逼,单次采集 0.2~0.5s,失败降级不阻塞执行
- case_result 模型新增 server_log_errors JSON 字段(database.py 启动补列兼容)
- execution_service 用例循环记录精确起止窗口,采集结果随回调落库 + WebSocket 广播
- executions 路由新增 GET .../results/{case_id}/server-logs 独立拉取接口
- config 新增 TARGET_LOG_MONITOR_ENABLED(默认开)/ TARGET_LOG_TAIL_LINES=500

前端可视化(ServerLogDrawer):
- 新增 ServerLogDrawer.vue 暗黑控制台风格日志抽屉(服务下拉徽标/级别过滤/搜索/复制)
- Execution 展开行新增「服务端异常」列 + 「服务日志」按钮,StepDetailPanel 联动入口
- executions.ts 新增 getServerLogs API 封装

验证与部署:
- 本地 8001 重启后 API 实测通过(结构/404 分支),pytest 471/471,前端 build 零报错
- 5.60 已部署验收(后端 6 文件 MD5 核对 + dist 95 文件 + settings 实读)
- 踩坑沉淀:config.py 并行部署覆盖回滚、.env 对 os.getenv 写法不生效、
  PLATFORM_BASE_URL 需部署后核对(HANDOFF 详录)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 a4fe8140
# PRD 需求文档 — UI 执行监控目标服务器 Java 服务日志
> **版本**: 1.0
> **日期**: 2026-09-08
> **模块**: 执行中心 / 目标机监控 / 报告排查
> **状态**: 待实现
---
## 一、问题背景
### 1.1 现状与痛点
平台在执行 UI 自动化测试任务时(包括手动批量执行与定时任务执行),目标被测系统均为统一管理平台(默认 `https://192.168.5.44`,由 `config.py` 中的 `settings.TARGET_URL` 配置)。
当 UI 步骤执行失败时(例如出现「元素定位超时」、「文本断言失败」、「网络请求 500」等),当前的系统行为仅记录:
1. Playwright 端的执行结果状态与耗时;
2. 失败步骤的前端截图;
3. 浏览器的 Console 输出与网络拦截记录。
这导致测试人员与开发人员排查问题时,**无法感知目标服务器上微服务的内部健康状态**
- 前端报 500 时,必须手动登录被测机(192.168.5.44),在多达 9 个微服务目录的日志文件中通过 `grep` 检索报错;
- 异步业务异常(如消息派发超时、MQ 挂起、定时调度触发失败)在 UI 上虽然勉强通过或局部失败,但服务端已产生大量 `NullPointerException` 或数据库死锁,未能被及时捕获。
### 1.2 建设目标
在 UI 执行器执行测试用例的生命周期内,系统自动增量探测并捕获目标服务器(SUT)上各核心 Java 服务的日志异常,实现**用例级时间窗口精准关联**,并将错误信息在执行中心可视化展示。
---
## 二、目标服务与日志范围
根据对被测系统(192.168.5.44)的实机进程与文件句柄探测,重点监控以下 9 个 Java 服务(直接对应其当前工作的日志输出目录):
| 序号 | 微服务名称 | 部署目录 / 日志文件路径 | 核心关注场景 |
|---|---|---|---|
| 1 | `ubains-gateway` | `/var/www/java/api/auth/auth-sso-gatway/logs/ubains-INFO-AND-ERROR.log` | 路由鉴权、请求转发、跨域异常 |
| 2 | `ubains-auth` | `/var/www/java/api/auth/auth-sso-auth/logs/ubains-INFO-AND-ERROR.log` | 用户登录、Token 验证、验证码核对 |
| 3 | `ubains-modules-system` | `/var/www/java/api/auth/auth-sso-system/logs/ubains-INFO-AND-ERROR.log` | 系统字典、菜单权限、组织架构 |
| 4 | `ubains-meeting-inner-api` | `/var/www/java/api/java-meeting/java-meeting2.0/logs/ubains-ERROR.log` | 内部会议调度逻辑、冲突排查 |
| 5 | `ubains-meeting-quartz` | `/var/www/java/api/java-meeting/java-quartz/logs/ubains-ERROR.log` | 会议定时任务、生命周期轮询 |
| 6 | `ubains-meeting-mqtt` | `/var/www/java/api/java-meeting/java-mqtt/logs/ubains-ERROR.log` | 设备消息下发、状态上报 MQTT |
| 7 | `ubains-meeting-message-scheduling` | `/var/www/java/api/java-meeting/java-message-scheduling/logs/ubains-ERROR.log` | 消息队列派发、通知推送 |
| 8 | `ubains-meeting-api` | `/data/services/api/java-meeting/java-meeting-extapi/logs/ubains-ERROR.log` | 外部开放 API、终端对接 |
| 9 | `ubains-meeting` | `/var/www/java/api/java-meeting/java-meeting2.0/logs/ubains-ERROR.log` | 会议综合业务模块 |
---
## 三、功能性需求
### 3.1 目标主机判定
- 统一使用系统全局配置指定的被测系统:从 `settings.TARGET_URL`(或 `TARGET_MONITOR_HOST`)自动推断目标主机 IP,无需在创建执行时由用户手动重复挑选。
### 3.2 用例级日志时间窗口采集
- **采集时机**:在 `execution_service.py` 内部串行循环中,用例开始执行前记录秒级时间戳 $T_{start}$,执行完毕后记录秒级时间戳 $T_{end}$;
- **增量抓取**:通过复用的 SSH 长连接,执行聚合命令仅检索日志文件末尾(默认前 500 行),使用时间戳区间 `[$T_{start}$, $T_{end}$]` 与关键字 `ERROR|Exception` 过滤;
- **数据结构持久化**:将采集到的异常摘要落库至 `case_results.server_log_errors`(JSON 结构),格式包含各服务的错误条数、日志路径及异常堆栈片段。
### 3.3 实时通知与广播
- 用例完成后,除了原有的用例结果字段,`server_log_errors` 也会实时通过 WebSocket(`case_complete` 事件)推送至前端客户端。
### 3.4 前端界面呈现
1. **执行列表展开行(用例级直观感知)**
- 在用例列表的「错误信息」/「服务端异常」列中,若捕获到后端异常,渲染醒目的红色 `el-tag`(例如:`ubains-meeting-inner-api: 2`);
- 在列表操作列中,在「查看步骤」旁增加「服务日志」按钮。
2. **服务日志排查抽屉(`ServerLogDrawer.vue`)**
- 点击「服务日志」弹出宽幅 `el-drawer`(宽度 70%),深色终端风格(`#1e1e1e`);
- 提供按服务筛选的 Tab、日志级别过滤(All / ERROR / WARN)、关键词快速过滤与一键复制功能;
3. **步骤详情弹窗联动(`StepDetailPanel.vue`)**
- 弹窗顶部右上角提供快捷按钮,方便从前端步骤快速下钻查看服务端伴随日志。
---
## 四、非功能性需求
1. **执行低延迟与低开销**
- 单条用例的日志采集耗时必须控制在 **500ms** 以内;
- 采用单条复合 SSH 命令完成所有 9 个服务的扫描,避免发起多次往返;
- 窗口内日志最多采样 100 行或 60KB,避免 MySQL 存储过载。
2. **容错与静默降级**
- 若目标机 SSH 连接失败、网络超时或凭据错误,只记录系统 warning 日志,严禁中断或判死正在运行的自动化测试用例。
3. **双库兼容性**
- 支持 SQLite 与 MySQL 双数据库;使用标准 JSON 字段映射,MySQL 采用动态补列机制(防止环境差异导致启动崩溃)。
---
## 五、验收标准
1. **正确关联**:故意触发一个后端抛 500 的接口(如重复创建同一资源),该用例结果行中必须显示红色异常 Tag,点击可查看到精准对应的 Java 异常堆栈。
2. **无异常零误报**:执行正常用例(无 ERROR 产生的用例)时,日志展示为 `- (无异常)`,不会抓取到其他无关时间段的旧日志。
3. **前端功能完整**:支持在抽屉中切换微服务 Tab、输入关键词搜索、点击一键复制能成功将堆栈复制到系统剪贴板。
# 执行计划 — UI 执行监控目标服务器 Java 服务日志
> **版本**: 1.0
> **日期**: 2026-09-08
> **关联 PRD**: `Docs/PRD/需求文档/执行中心/_PRD_需求文档_UI执行监控目标服务器Java服务日志.md`
> **模块**: 执行中心 / 目标机监控 / 前端可视化
> **状态**: 待执行
---
## 一、阶段划分与执行路径
本项目分为 5 个 Phase 进行分阶段开发和验证:
```
Phase 1: 数据模型与配置层
Phase 2: 后端日志采集器开发 (TargetLogMonitor)
Phase 3: 执行服务挂载与 API 端点
Phase 4: 前端抽屉组件与执行页联动
Phase 5: 联调验证与实测
```
---
## 二、详细任务拆解
### Phase 1: 数据模型与配置层
1. **配置扩展(`backend/app/config.py`)**
- 增加 `TARGET_LOG_MONITOR_ENABLED: bool`(默认 `true`,控制是否在 UI 自动化执行期间抓取日志);
- 增加 `TARGET_LOG_TAIL_LINES: int = 500`(限制单服务最大 tail 行数)。
2. **模型修改(`backend/app/models/case_result.py`)**
-`CaseResult` 模型新增 `server_log_errors: Mapped[dict] = mapped_column(JSON, default=dict, nullable=True)`
- 更新 `to_dict()` 序列化字典,暴露 `server_log_errors`
3. **数据库自愈兼容(`backend/app/database.py`)**
-`_ensure_columns` 或类似自检方法中,加入检查 `case_results` 表是否存在 `server_log_errors` 列的逻辑,缺失则执行 `ALTER TABLE case_results ADD COLUMN server_log_errors JSON NULL`,保证 SQLite 和 MySQL 环境平滑启动。
### Phase 2: 后端日志采集器开发 (`backend/app/executors/target_log_monitor.py`)
1. **复用 SSH 连接池能力**
- 继承或参考 `TargetResourceMonitor``paramiko` 连接逻辑,保持主机 `settings.TARGET_MONITOR_HOST`(默认 192.168.5.44)长连接。
2. **单条 Bash 复合扫描脚本设计**
- 遍历 9 个微服务日志路径(`/var/www/java/api/...``/data/services/api/...`);
- 对每个存在的日志文件执行 `tail -n 500`,提取带有 `ERROR``Exception` 的日志行,并带上时间戳;
- 在 Python 端过滤出位于用例起止时间 `[start_time, end_time]` 内的日志内容(容差 2 秒);
3. **安全截断与数据结构封装**
- 单服务日志字符数超长截断(最大 20,000 字符),防止数据库膨胀;
- 提供同步接口:`collect_logs_for_window(start_time: datetime, end_time: datetime) -> dict`
### Phase 3: 执行服务挂载与 API 端点
1. **执行服务挂载(`backend/app/services/execution_service.py`)**
-`run_execution()` 初始化 `TargetLogMonitor` 实例;
-`run_all_cases_sync()` 用例循环中:
- 执行前记录 `case_start = datetime.now()`
- 执行完 `executor.execute_case()` 后记录 `case_end = datetime.now()`
- 调用 `log_monitor.collect_logs_for_window(case_start, case_end)` 获取服务日志;
- 将结果写入 `case_result_obj.server_log_errors`
-`_persist_case_result` 中完成落库 commit,并通过 WebSocket 向外广播带有 `server_log_errors``case_complete` 消息。
2. **API 路由与服务层查询(`backend/app/routers/executions.py`)**
- 新增端点 `GET /api/executions/{execution_id}/results/{case_id}/server-logs`,返回指定用例的服务端日志详情。
### Phase 4: 前端抽屉组件与执行页联动
1. **抽屉组件开发(`frontend/src/components/ServerLogDrawer.vue`)**
- 采用 `el-drawer`(宽 70%),暗黑终端风格主题(`#1e1e1e`);
- 包含服务切换 Tab(显示各服务的 ERROR 计数)、日志级别过滤单选、关键词搜索框、一键复制堆栈按钮;
- 日志内容区高亮关键词,显示错误行号。
2. **执行页对接(`frontend/src/views/Execution.vue`)**
- 在展开行内嵌的用例表格中,增加「服务端异常」列或在「错误信息」中显示红标签 `[ubains-meeting: 2]`
- 操作列增加「服务日志」按钮,点击拉起 `ServerLogDrawer`
- WebSocket `case_complete` 事件触发时,更新局部用例的 `server_log_errors` 状态。
3. **步骤详情联动(`frontend/src/components/StepDetailPanel.vue`)**
- 在步骤弹窗右上角增加「服务日志」快捷按钮,支持带参打开同一个日志抽屉。
4. **API 封装(`frontend/src/api/executions.ts`)**
- 增加 `getServerLogs(executionId, caseId)`
### Phase 5: 联调验证与实测
1. 运行 `python -m app.executors.target_log_monitor` 进行单模块连通性与采集耗时验证;
2. 启动前后端服务,触发一次 UI 自动化用例执行;
3. 验证日志采集入库、WebSocket 实时推送、展开行异常 Tag 显示及抽屉交互功能。
---
## 三、风险与应对预案
| 风险点 | 潜在影响 | 应对预案 |
|---|---|---|
| 目标机 SSH 响应变慢 | 拖慢 UI 自动化执行 | 设置 SSH 超时为 3 秒;用例采集在子线程或受限循环中执行,超时直接返回空结果不卡死 |
| 某些服务日志未配置滚动产生巨型日志 | `tail``grep` 缓慢 | 强制只读最后 500 行,禁止全量 `cat` |
| 日志时间与执行机时钟不一致 | 窗口匹配漏抓或误判 | 日志解析支持前后 2~3 秒的软容差窗口 |
| MySQL 字段长度过载 | 写入报 `Data too long` | 强制在 Python 端对堆栈摘要截断(最大 20KB/服务) |
......@@ -109,11 +109,22 @@
| `frontend/src/components/StepDetailPanel.vue` | 步骤概览右上角新增「查看对应服务端日志」入口,点击 emit `open-server-logs` 联动抽屉 |
| `frontend/src/api/executions.ts` | 新增 `getServerLogs(executionId, caseId)` API 封装 |
### ✅ 验证结果
- 后端:直接调用 `get_case_server_logs` 路由验证 `has_errors/total_errors/services` 结构与 404 分支 ✅
### ✅ 验证结果(2026-09-09 本地 + 5.60 实机全通过)
- 本地 8001 重启后 API 实测:`GET .../server-logs` 返回 `has_errors/total_errors/services` 完整结构(含 meeting-inner-api 2 条 ERROR 片段)+ 404 分支 ✅
- 后端 pytest **471/471** 全绿 + 前端 `npm run build` 零报错 ✅
- DB:`server_log_errors` 列迁移补列成功,`case_complete` WebSocket 广播已含该字段 ✅
- 前端:`vue-tsc` 类型检查 0 错误 + `vite build` 通过 + ESLint 清理 ✅
- ⚠️ **8001 后端为 stale 进程**(未带 `--reload`,无新路由),需重启后才能进行真实用例 E2E 验收(采集耗时/服务 Tab 切换/搜索/复制)
### ✅ 5.60 部署(2026-09-09)
| 步骤 | 结果 |
|------|------|
| 后端 6 文件 scp(target_log_monitor/config/database/case_result/executions/execution_service)+ MD5 全核对 | ✅ |
| 前端 dist 95 文件全量重传 + 重启 | ✅ healthy |
| OpenAPI 路由注册 + 404 分支实测 | ✅ |
### 🕳️ 5.60 部署踩坑(勿重复踩)
1. **config.py 并行部署覆盖**:上传 6 分钟后被另一窗口的并行部署覆盖回旧版(缺 TARGET_LOG 两项),导致 `execution_service.py` 运行时 `AttributeError`(UI 执行全挂)。**部署后必须二次核对 MD5 + 容器内 `settings.TARGET_LOG_MONITOR_ENABLED` 实读**
2. **`.env` 不生效**:config.py 用 `os.getenv(...)` 类定义时求值,pydantic `env_file=".env"` 对该写法**不生效**(容器内实测 .env 存在但读不到);只有真实环境变量/compose environment 有效。
3. **PLATFORM_BASE_URL 修复**:5.60 的 config.py 已 sed 恢复默认值 `http://192.168.5.60`(钉钉报告链接前缀,备份 `/tmp/config.py.bak_031724`);**本地→远程部署 config.py 会抹掉此值,部署后必须检查**(或改用 compose environment 固化)。
### 📁 文档沉淀
- `Docs/PRD/需求文档/执行中心/_PRD_需求文档_UI执行监控目标服务器Java服务日志.md`(状态:已完成)
......
......@@ -143,6 +143,10 @@ class Settings:
TARGET_MONITOR_INTERVAL: float = float(os.getenv("TARGET_MONITOR_INTERVAL", "1.0"))
TARGET_MONITOR_MAX_RETRIES: int = int(os.getenv("TARGET_MONITOR_MAX_RETRIES", "5"))
# 目标机 Java 服务日志监控(UI 自动化执行期间增量采集并关联用例)
TARGET_LOG_MONITOR_ENABLED: bool = os.getenv("TARGET_LOG_MONITOR_ENABLED", "true").lower() == "true"
TARGET_LOG_TAIL_LINES: int = int(os.getenv("TARGET_LOG_TAIL_LINES", "500"))
class Config:
"""Pydantic 配置"""
env_file = ".env"
......
......@@ -276,6 +276,8 @@ async def _ensure_columns(conn) -> None:
# 性能测试:快照资源监控数据(回放模式展示本机/目标机资源,2026-08-25 新增)
("performance_snapshots", "resource", "JSON"),
("performance_snapshots", "target_resource", "JSON"),
# UI 自动化执行:目标服务器 Java 服务异常日志采样(2026-09-08 新增)
("case_results", "server_log_errors", "JSON"),
]
def _do_ensure(sync_conn) -> None:
......
此差异已折叠。
......@@ -81,6 +81,12 @@ class CaseResult(Base):
retry_count: Mapped[int] = mapped_column(Integer, default=0, comment="重试次数")
error_message: Mapped[str] = mapped_column(Text, nullable=True, default="", comment="错误信息")
steps_result: Mapped[list] = mapped_column(JSON, default=list, comment="步骤结果")
server_log_errors: Mapped[Optional[dict]] = mapped_column(
JSON,
default=dict,
nullable=True,
comment="目标服务器 Java 服务异常日志采样"
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
......@@ -116,5 +122,6 @@ class CaseResult(Base):
"retry_count": self.retry_count,
"error_message": self.error_message,
"steps_result": self.steps_result or [],
"server_log_errors": self.server_log_errors or {},
"created_at": self.created_at.isoformat() if self.created_at else None,
}
......@@ -358,6 +358,47 @@ async def get_case_result(
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.get("/{execution_id}/results/{case_id}/server-logs", response_model=dict)
async def get_case_server_logs(
execution_id: str,
case_id: str,
service: ExecutionService = Depends(get_execution_service),
):
"""
获取单个用例在执行期间捕获的目标服务器 Java 服务日志异常
Args:
execution_id (str): 执行记录ID
case_id (str): 用例ID
service (ExecutionService): 执行服务实例
Returns:
dict: 服务端日志异常数据,含各服务统计与堆栈片段
"""
try:
result = await service.get_case_result(execution_id, case_id)
if not result:
raise HTTPException(status_code=404, detail="用例结果不存在")
server_logs = result.server_log_errors or {}
return {
"execution_id": execution_id,
"case_id": case_id,
"case_name": result.case_name,
"start_time": result.start_time.isoformat() if result.start_time else None,
"end_time": result.end_time.isoformat() if result.end_time else None,
"has_errors": server_logs.get("has_errors", False),
"total_errors": server_logs.get("total_errors", 0),
"services": server_logs.get("services", {}),
}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取用例服务端日志失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
# ==================== 删除接口 ====================
@router.delete("/{execution_id}", status_code=204)
......
......@@ -36,6 +36,7 @@ from app.executors.playwright_executor import (
StepResult,
CaseExecutionResult,
)
from app.executors.target_log_monitor import TargetLogMonitor
from app.websocket.manager import manager
from app.utils.id_generator import generate_id
from app.config import settings
......@@ -687,7 +688,7 @@ class ExecutionService:
# 在线程池中为每个用例使用独立执行器
loop = asyncio.get_event_loop()
async def _persist_case_result(case_result_obj, result):
async def _persist_case_result(case_result_obj, result, server_log_errors=None):
"""单个用例执行完毕:立即写库 + 更新统计 + 广播(跨线程提交到事件循环)"""
try:
# 已取消 → 不覆盖 skipped 状态,直接跳过写入
......@@ -706,6 +707,7 @@ class ExecutionService:
)
case_result_obj.end_time = datetime.now()
case_result_obj.retry_count = result.retry_count
case_result_obj.server_log_errors = server_log_errors or {}
# 截断错误信息:断言失败会把整段页面 body/CSS 带进 error_message,
# MySQL TEXT 列仅 65535 字节,超长直接 1406 导致本用例结果写库失败。
error_msg = result.error_message
......@@ -771,6 +773,9 @@ class ExecutionService:
executor._execution_id = execution_id
results = []
# 目标服务日志采集器实例(在整个用例批次中复用 SSH 长连接)
log_monitor = TargetLogMonitor() if settings.TARGET_LOG_MONITOR_ENABLED else None
try:
executor.start()
logger.info(f"[run_all_cases_sync] 执行器启动成功")
......@@ -816,14 +821,35 @@ class ExecutionService:
except Exception as e:
logger.error(f"✗ 步骤回调提交失败: {str(e)}")
# ★ 记录用例级时间窗口起点(用于目标服务日志关联)
case_start = datetime.now()
result = executor.execute_case(case=case_dict, callback=step_callback)
case_end = datetime.now()
logger.info(f"[run_all_cases_sync] 用例 {case_name} 执行完成, status={result.status}")
results.append(result)
# ★ 目标服务器 Java 日志采集(尽力而为,失败不阻塞主流程):
# 在专用 worker 线程内同步调用(SSH 阻塞),采集用例起止窗口内的
# ERROR/Exception,随后随 result_callback 一并持久化与广播。
server_log_errors = {}
try:
if log_monitor is not None:
server_log_errors = log_monitor.collect_logs_for_window(
case_start, case_end
)
if server_log_errors.get("total_errors", 0) > 0:
logger.info(
f"[run_all_cases_sync] 用例 {case_name} 捕获目标机异常日志 "
f"{server_log_errors['total_errors']} 条: "
f"{list(server_log_errors['services'].keys())}"
)
except Exception as log_e:
logger.warning(f"[日志监控] 用例 {case_name} 采集异常(忽略): {log_e}")
# ★ 实时写库:跨线程提交到事件循环(不阻塞 Playwright 线程)
try:
asyncio.run_coroutine_threadsafe(
result_callback(case_result_obj, result),
result_callback(case_result_obj, result, server_log_errors),
ev_loop,
)
except Exception as e:
......@@ -840,6 +866,11 @@ class ExecutionService:
finally:
logger.info("[run_all_cases_sync] 停止执行器")
executor.stop()
if log_monitor is not None:
try:
log_monitor.close()
except Exception:
pass
try:
# 准备用例数据列表
......
......@@ -80,6 +80,16 @@ export const executionApi = {
return response as any
},
/**
* 获取用例执行期间的目标服务器 Java 日志
*/
async getServerLogs(executionId: string, caseId: string): Promise<any> {
const response = await request.get(
`/api/executions/${executionId}/results/${caseId}/server-logs`
)
return response as any
},
/**
* 删除执行记录(不影响测试用例本身)
*/
......
<!--
组件名称:ServerLogDrawer.vue
组件描述:服务端日志抽屉 —— 展示用例执行窗口内目标机 Java 服务的 ERROR/Exception 日志,
支持服务切换、级别过滤、关键词搜索与一键复制堆栈。
@author czj
@date 2026-09-08
-->
<template>
<el-drawer
v-model="visible"
:title="drawerTitle"
size="70%"
:destroy-on-close="true"
custom-class="server-log-drawer"
>
<div class="drawer-header">
<el-select
v-model="selectedService"
placeholder="选择服务"
style="width: 220px"
clearable
>
<el-option
v-for="service in services"
:key="service"
:label="formatServiceLabel(service)"
:value="service"
>
<span>{{ formatServiceLabel(service) }}</span>
<span v-if="errorCounts[service]" class="error-count">{{ errorCounts[service] }}条异常</span>
</el-option>
</el-select>
<el-input
v-model="searchKeyword"
placeholder="搜索日志内容..."
clearable
prefix-icon="Search"
style="width: 280px"
/>
<el-select
v-model="logLevelFilter"
placeholder="日志级别"
style="width: 140px"
>
<el-option label="全部" value="all" />
<el-option label="ERROR" value="ERROR" />
<el-option label="WARN" value="WARN" />
<el-option label="INFO" value="INFO" />
</el-select>
<el-button
type="primary"
@click="copyAllLogs"
icon="DocumentCopy"
v-if="selectedService"
>
一键复制全部日志
</el-button>
</div>
<div class="drawer-content">
<div
v-if="selectedService"
class="log-container"
ref="logContainer"
>
<div
v-for="(block, index) in filteredLogs"
:key="index"
class="log-block"
:class="{ 'has-error': isErrorBlock(block) }"
>
<span class="timestamp">{{ block.timestamp }}</span>
<span class="log-level">{{ block.level }}</span>
<span class="content">{{ block.content }}</span>
<div class="stack" v-if="block.stack">
<div v-for="(line, lineIdx) in block.stack" :key="lineIdx" class="stack-line">{{ line }}</div>
</div>
</div>
</div>
<el-empty v-else description="请选择服务查看日志" image-size="100" />
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
interface LogBlock {
timestamp: string
level: string
content: string
stack?: string[]
}
const props = defineProps<{
visible: boolean
executionId: string
caseId: string
caseName: string
serverLogErrors: any
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const drawerTitle = computed(() => `服务日志 - ${props.caseName}`)
const visible = ref(props.visible)
const selectedService = ref('')
const searchKeyword = ref('')
const logLevelFilter = ref('all')
const errorCounts = ref<Record<string, number>>({})
// 原始日志块(未过滤),filterLogs 基于它做过滤
const allLogs = ref<LogBlock[]>([])
const filteredLogs = ref<LogBlock[]>([])
const services = computed(() => {
if (!props.serverLogErrors?.services) return []
return Object.keys(props.serverLogErrors.services).sort()
})
const logContainer = ref<HTMLElement | null>(null)
// 同步父组件 visible 状态:打开时重置选择并加载首个服务日志
watch([props.visible, props.serverLogErrors], ([newVisible]) => {
if (newVisible) {
visible.value = true
selectedService.value = services.value[0] || ''
loadServiceLogs()
} else {
visible.value = false
}
})
/** 关闭抽屉:通知父组件同步 visible 状态 */
const handleClose = () => {
visible.value = false
emit('close')
}
const loadServiceLogs = () => {
if (!selectedService.value || !props.serverLogErrors?.services) {
allLogs.value = []
filteredLogs.value = []
return
}
const serviceData = props.serverLogErrors.services[selectedService.value]
if (!serviceData) {
allLogs.value = []
filteredLogs.value = []
return
}
const logs: LogBlock[] = []
const snippets = serviceData.snippets || []
for (const snippet of snippets) {
const lines = snippet.split('\n')
const header = (lines[0] || '').trim()
// 头部行同时含时间戳与级别,如:
// 2026-09-08 16:20:15 ERROR [http-nio-8080-exec-1] c.u.m.MeetingController: xxx
const tsMatch = header.match(/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/)
const timestamp = tsMatch ? tsMatch[1].replace('T', ' ') : ''
let level = ''
if (/\bERROR\b/.test(header)) level = 'ERROR'
else if (/\bWARN\b/.test(header)) level = 'WARN'
else if (/\bINFO\b/.test(header)) level = 'INFO'
else if (/Exception|\bError\b/.test(header)) level = 'ERROR'
// 内容取级别关键字之后的正文;取不到则回退整行
let content = header
if (level) {
const idx = header.indexOf(level)
if (idx >= 0) content = header.slice(idx + level.length).trim() || header
}
logs.push({
timestamp,
level,
content,
stack: lines.length > 1 ? lines.slice(1) : undefined
})
}
allLogs.value = logs
errorCounts.value[selectedService.value] = serviceData.error_count || 0
filterLogs()
}
const filterLogs = () => {
const level = logLevelFilter.value
if (level === 'all') {
filteredLogs.value = allLogs.value
} else {
filteredLogs.value = allLogs.value.filter(block => block.level === level)
}
const keyword = searchKeyword.value.toLowerCase().trim()
if (keyword) {
filteredLogs.value = filteredLogs.value.filter(block => {
return (
block.content.toLowerCase().includes(keyword) ||
(block.stack && block.stack.some(line => line.toLowerCase().includes(keyword)))
)
})
}
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
}
const isErrorBlock = (block: LogBlock): boolean => {
return block.level === 'ERROR' || block.content.toUpperCase().includes('EXCEPTION')
}
const copyAllLogs = async () => {
if (!selectedService.value || !filteredLogs.value.length) return
try {
const text = filteredLogs.value
.map(block => {
const lines = [block.timestamp ? `${block.timestamp} ${block.level} ${block.content}` : block.content]
if (block.stack) {
lines.push(...block.stack.map(line => ` ${line}`))
}
return lines.join('\n')
})
.join('\n\n')
await navigator.clipboard.writeText(text)
ElMessage.success('日志已复制到剪贴板')
} catch {
ElMessage.error('复制失败')
}
}
const formatServiceLabel = (service: string): string => {
return service
.replace('ubains-', '')
.replace(/-inner-api$/, ' - InnerAPI')
.replace(/-quartz$/, ' - Quartz')
.replace(/-mqtt$/, ' - MQTT')
.replace(/-message-scheduling$/, ' - MessageScheduling')
.replace(/-api$/, ' - API')
.replace('-gateway', ' - Gateway')
.replace('-auth', ' - Auth')
.replace('-modules-system', ' - Modules')
}
watch(selectedService, () => {
loadServiceLogs()
})
watch([searchKeyword, logLevelFilter], filterLogs)
</script>
<style lang="scss" scoped>
.server-log-drawer {
.drawer-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f5f5f5;
border-bottom: 1px solid #e9e9e9;
}
.drawer-content {
padding: 16px;
background: #1e1e1e;
min-height: 400px;
}
.log-container {
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
color: #d4d4d4;
background: #1e1e1e;
padding: 16px;
border-radius: 4px;
overflow-y: auto;
max-height: 500px;
white-space: pre-wrap;
word-break: break-all;
}
.log-block {
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 3px;
border-left: 3px solid #555;
white-space: pre-wrap;
word-break: break-all;
}
.log-block.has-error {
background: #2a0000;
border-left-color: #f56c6c;
}
.timestamp {
color: #888;
margin-right: 8px;
}
.log-level {
color: #f56c6c;
margin-right: 8px;
font-weight: bold;
}
.content {
color: #d4d4d4;
}
.stack {
margin-top: 4px;
padding-left: 16px;
color: #a0a0a0;
font-size: 12px;
}
.stack-line {
margin-bottom: 2px;
}
.error-count {
background: #f56c6c;
color: white;
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
}
.el-select, .el-input {
margin-right: 12px;
}
}
</style>
\ No newline at end of file
......@@ -14,6 +14,17 @@
<span class="summary-value" :class="item.class">{{ item.count }}</span>
<span class="summary-label">{{ item.label }}</span>
</div>
<!-- 服务端日志联动入口 -->
<el-button
v-if="hasServerErrors"
type="warning"
plain
class="server-log-entry"
@click="$emit('open-server-logs')"
>
<el-icon><Warning /></el-icon>
查看对应服务端日志 ({{ serverErrorCount }})
</el-button>
</div>
<!-- 步骤列表 -->
......@@ -151,6 +162,11 @@ const props = defineProps<{
steps: StepResult[]
caseName?: string
executionId?: string
serverLogErrors?: any
}>()
defineEmits<{
(e: 'open-server-logs'): void
}>()
// 展开的日志
......@@ -177,6 +193,14 @@ const summaryStats = computed(() => {
]
})
/** 是否存在服务端异常数据(用于展示联动入口) */
const hasServerErrors = computed(() => {
return !!props.serverLogErrors && props.serverLogErrors.total_errors > 0
})
/** 服务端异常总数(标签徽标) */
const serverErrorCount = computed(() => props.serverLogErrors?.total_errors || 0)
const getStepColor = (status: string) => {
const map: Record<string, string> = {
passed: '#67c23a',
......@@ -277,6 +301,11 @@ const getScreenshotUrl = (screenshot: string) => {
color: #909399;
}
}
.server-log-entry {
margin-left: auto;
align-self: center;
}
}
.step-list {
......
......@@ -156,7 +156,20 @@
<span v-else class="no-error">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="服务端异常" width="130" align="center">
<template #default="{ row: r }">
<el-tag
v-if="r.server_log_errors && r.server_log_errors.total_errors > 0"
type="danger"
size="small"
effect="dark"
>
{{ formatServerErrors(r.server_log_errors) }}
</el-tag>
<span v-else class="no-error">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row: r }">
<el-button
size="small"
......@@ -167,6 +180,15 @@
>
查看步骤
</el-button>
<el-button
size="small"
type="warning"
link
@click="openServerLogDrawer(row, r)"
v-if="r.server_log_errors && r.server_log_errors.total_errors > 0"
>
服务日志
</el-button>
</template>
</el-table-column>
</el-table>
......@@ -321,9 +343,21 @@
:steps="currentStepResult"
:case-name="currentCaseName"
:execution-id="currentExecutionId"
:server-log-errors="currentServerLogErrors"
@open-server-logs="openServerLogFromStepDetail"
/>
</el-dialog>
<!-- 服务端日志抽屉 -->
<ServerLogDrawer
:visible="serverLogDrawerVisible"
:execution-id="currentExecutionId"
:case-id="serverLogDrawerCaseId"
:case-name="serverLogDrawerCaseName"
:server-log-errors="serverLogDrawerData"
@close="serverLogDrawerVisible = false"
/>
<!-- 用例列表弹窗 -->
<el-dialog
v-model="caseListDialogVisible"
......@@ -372,6 +406,7 @@ import {
import { apiTestApi } from '@/api/apiTest'
import { WebSocketClient } from '@/utils/websocket'
import StepDetailPanel from '@/components/StepDetailPanel.vue'
import ServerLogDrawer from '@/components/ServerLogDrawer.vue'
// 安全测试 API 简易封装(与 executionApi 接口风格保持一致)
const securityApi = {
......@@ -428,6 +463,14 @@ const stepDetailVisible = ref(false)
const currentStepResult = ref<any[]>([])
const currentCaseName = ref('')
const currentExecutionId = ref('')
const currentCaseId = ref('')
const currentServerLogErrors = ref<any>(null)
// 服务端日志抽屉相关
const serverLogDrawerVisible = ref(false)
const serverLogDrawerCaseId = ref('')
const serverLogDrawerCaseName = ref('')
const serverLogDrawerData = ref<any>(null)
// 用例列表弹窗相关
const caseListDialogVisible = ref(false)
......@@ -592,9 +635,62 @@ const showStepDetail = (executionRow: any, caseResult: any) => {
currentExecutionId.value = executionRow.id
currentCaseName.value = caseResult.case_name
currentStepResult.value = caseResult.steps_result || []
currentServerLogErrors.value = caseResult.server_log_errors || null
currentCaseId.value = caseResult.case_id
stepDetailVisible.value = true
}
/**
* 从步骤详情弹窗跳转打开服务端日志抽屉
*/
const openServerLogFromStepDetail = () => {
openServerLogDrawer(
{ id: currentExecutionId.value },
{
case_id: currentCaseId.value,
case_name: currentCaseName.value,
server_log_errors: currentServerLogErrors.value,
}
)
}
/**
* 格式化服务端异常摘要标签
* 示例:meeting-inner-api:2 · gateway:1(最多显示 2 个服务,超出显示 +N)
*/
const formatServerErrors = (serverLogErrors: any): string => {
if (!serverLogErrors?.services) return `${serverLogErrors?.total_errors || 0} 条异常`
const entries = Object.entries(serverLogErrors.services) as [string, any][]
entries.sort((a, b) => (b[1]?.error_count || 0) - (a[1]?.error_count || 0))
const shown = entries
.slice(0, 2)
.map(([name, info]: [string, any]) => `${name.replace('ubains-', '')}:${info?.error_count || 0}`)
const more = entries.length > 2 ? ` +${entries.length - 2}` : ''
return `${shown.join(' ')}${more}`
}
/**
* 打开服务端日志抽屉
* 优先使用行内缓存的 server_log_errors 数据,缺失时调用 API 懒加载
*/
const openServerLogDrawer = async (executionRow: any, caseResult: any) => {
currentExecutionId.value = executionRow.id
serverLogDrawerCaseId.value = caseResult.case_id
serverLogDrawerCaseName.value = caseResult.case_name
let data = caseResult.server_log_errors
if (!data || !data.services) {
try {
data = await executionApi.getServerLogs(executionRow.id, caseResult.case_id)
} catch (error: any) {
ElMessage.error('加载服务日志失败: ' + (error?.message || error))
return
}
}
serverLogDrawerData.value = data
serverLogDrawerVisible.value = true
}
/**
* 显示用例列表弹窗
* 加载执行任务包含的所有用例名称
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论