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

feat(service-monitor): 报告对比功能 + 异常项趋势告警

阶段九功能增强(P1 级):

报告对比功能:
- 新增 compare_service.py 对比服务(5 类差异分类)
- 新增 API:对比 /reports/{id1}/compare/{id2}
- 新增 API:导出 /reports/{id1}/compare/{id2}/export
- 新增 compare.html 对比结果页模板
- report.html 新增"对比历史报告"按钮 + 弹窗
- 值变化阈值:默认 > 20%

异常项趋势告警:
- report_service.py 新增 check_consecutive_abnormal()
- runner_service.py 集成连续异常告警
- notification.html 新增告警配置区(阈值/级别/渠道)
- 冷却期机制:24 小时内不重复告警

文档:
- PRD_需求文档_报告对比功能.md
- PRD_需求文档_异常项趋势告警.md
- PRD_计划执行_报告对比功能.md
- PRD_计划执行_异常项趋势告警.md

测试:167 个测试全绿 Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 4bff86d5
# PRD 计划执行:异常项趋势告警
> 模块:服务监测(service_monitor)
> 优先级:P1
> 创建日期:2026-07-23
> 需求文档:`PRD_需求文档_异常项趋势告警.md`
---
## 执行计划
### 阶段一:后端告警检测逻辑(预估 2h)
**任务清单**
- [ ] 1.1 新增告警检测函数
- 文件:`skill/code/web/service_monitor/services/report_service.py`
- 函数:`check_consecutive_abnormal(target_id, current_report)`
- 函数:`_get_alert_config(target_id)` - 获取告警配置
- 函数:`_get_abnormal_items(report, alert_level)` - 提取异常项
- 函数:`_get_recent_reports(target_id, count)` - 查询最近报告
- 函数:`_is_consecutive(item_key, recent_reports, threshold)` - 检查连续性
- [ ] 1.2 实现冷却期机制
- 变量:`_ALERT_COOLDOWN` 字典(内存缓存)
- 函数:`_in_cooldown(target_id, item_key)` - 检查冷却期
- 函数:`_set_cooldown(target_id, item_key)` - 设置冷却期
- [ ] 1.3 报告完成回调集成
- 文件:`skill/code/web/service_monitor/services/runner_service.py`
- 位置:`_finish_run()` 函数内
- 逻辑:报告保存后调用 `check_consecutive_abnormal()`
**验收标准**
- 连续异常检测逻辑正确
- 冷却期机制生效
- 回调集成无误
---
### 阶段二:通知发送集成(预估 1h)
**任务清单**
- [ ] 2.1 新增告警消息模板
- 文件:`skill/code/web/service_monitor/services/runner_service.py`
- 函数:`_build_consecutive_alert_message(target_name, items)` - 构建告警消息
- 模板:文本格式 + Markdown 格式(钉钉/企微)
- [ ] 2.2 复用现有通知发送
- 调用:`_send_notification()` 函数
- 参数:目标配置中的通知渠道
- 逻辑:批量发送告警消息
- [ ] 2.3 消息链接生成
- 报告链接:包含 access_token(如果有)
- 格式:`{base_url}/service-monitor/report/{report_id}?token={token}`
**验收标准**
- 消息模板格式正确
- 通知发送成功
- 链接可访问
---
### 阶段三:前端配置页(预估 1h)
**任务清单**
- [ ] 3.1 修改通知配置页
- 文件:`skill/code/web/templates/service_monitor/notification.html`
- 新增:连续异常告警配置区
- 元素:开关、连续次数输入框、告警级别下拉框、通知渠道多选框
- [ ] 3.2 配置保存逻辑
- 复用:现有 `saveNotificationConfig()` 函数
- 扩展:表单数据包含 `alert_on_consecutive` 字段
- [ ] 3.3 配置加载逻辑
- 复用:现有加载函数
- 渲染:根据配置显示开关状态和配置值
**验收标准**
- 配置区正常显示
- 开关控制生效
- 保存/加载正常
---
### 阶段四:单元测试(预估 1h)
**任务清单**
- [ ] 4.1 新增测试文件
- 文件:`skill/code/tests/test_consecutive_alert.py`
- 用例:
- 连续异常检测(3 次、5 次)
- 告警级别过滤(仅严重 / 警告+严重)
- 冷却期机制
- 无历史报告/报告不足情况
- [ ] 4.2 覆盖率目标
- 目标:告警逻辑 > 80%
**验收标准**
- 所有测试用例通过
- 覆盖率达标
---
### 阶段五:集成测试 + 部署(预估 1h)
**任务清单**
- [ ] 5.1 本地集成测试
- 测试:完整流程(配置 → 巡检 → 告警)
- 验证:边界情况(配置禁用、无历史报告)
- [ ] 5.2 部署到 5.60
- 更新:`deploy/upload_to_server.py` 文件清单
- 执行:部署脚本
- 验证:健康检查 + 功能测试
- [ ] 5.3 更新文档
- 更新:`HANDOFF_服务监测.md`
- 提交:Git commit
**验收标准**
- 本地测试通过
- 部署成功
- 5.60 功能验证通过
---
## 关键文件清单
### 修改文件
| 文件 | 改动 |
|------|------|
| `service_monitor/services/report_service.py` | 新增连续异常检测函数 |
| `service_monitor/services/runner_service.py` | 新增告警消息模板 + 回调集成 |
| `templates/service_monitor/notification.html` | 新增连续异常告警配置区 |
### 新增文件
| 文件 | 说明 |
|------|------|
| `tests/test_consecutive_alert.py` | 单元测试 |
---
## 风险与注意事项
### 风险 1:内存冷却期丢失
**问题**:服务重启后 `_ALERT_COOLDOWN` 内存字典清空,可能重复告警
**缓解**
- 可接受(重启频率低)
- 后续可改为持久化存储(Redis/SQLite)
### 风险 2:历史报告查询性能
**问题**:大目标历史报告多,查询最近 N 份可能耗时
**缓解**
- 查询时限制数量(`limit=N`
- 报告数据已按时间索引,查询效率可接受
### 风险 3:通知发送失败
**问题**:钉钉/企微 API 可能超时或限流
**缓解**
- 复用现有通知重试机制
- 发送失败记录日志,不阻断巡检流程
---
## 依赖关系
- 依赖现有通知配置结构(`targets.json`
- 依赖现有通知发送机制(`runner_service._send_notification()`
- 无外部依赖新增
---
## 完成定义(DoD)
- [ ] 所有阶段任务完成
- [ ] 单元测试覆盖率 > 80%
- [ ] 本地集成测试通过
- [ ] 部署到 5.60 成功
- [ ] 功能验证通过
- [ ] 文档更新并提交
\ No newline at end of file
# PRD 计划执行:报告对比功能
> 模块:服务监测(service_monitor)
> 优先级:P1
> 创建日期:2026-07-23
> 需求文档:`PRD_需求文档_报告对比功能.md`
---
## 执行计划
### 阶段一:后端对比逻辑 + API(预估 2h)
**任务清单**
- [ ] 1.1 新增 `compare_service.py` 对比服务
- 文件:`skill/code/web/service_monitor/services/compare_service.py`
- 函数:`compare_reports(report_a_id, report_b_id)`
- 函数:`_classify_diff(item_a, item_b)` - 分类差异类型
- 函数:`_calculate_value_change(value_a, value_b)` - 计算数值变化率
- 函数:`_extract_numeric_value(value)` - 提取数值
- [ ] 1.2 新增对比 API 路由
- 文件:`skill/code/web/service_monitor/routes.py`
- 路由:`GET /reports/<report_id_a>/compare/<report_id_b>`
- 验证:两份报告属于同一目标
- 错误处理:报告不存在(404)、目标不匹配(400)
- [ ] 1.3 新增导出接口
- 路由:`GET /reports/<report_id_a>/compare/<report_id_b>/export`
- 参数:`format`(markdown/html)
- 导出函数:`export_compare_markdown(data)` / `export_compare_html(data)`
**验收标准**
- API 返回正确的差异分类
- 数值变化率计算准确(变化 > 20% 标记)
- 导出文件格式正确
---
### 阶段二:前端报告选择器(预估 1h)
**任务清单**
- [ ] 2.1 修改报告详情页
- 文件:`skill/code/web/templates/service_monitor/report.html`
- 新增:"对比历史报告"按钮(顶部操作栏)
- 权限:仅在登录状态下显示(`{% if not token_auth %}`
- [ ] 2.2 新增报告选择弹窗
- 元素:弹窗组件
- 功能:显示历史报告列表(最多 20 份)
- 交互:选择报告后跳转到对比页
- [ ] 2.3 报告列表数据获取
- API:`GET /api/service-monitor/reports?target_id={id}&limit=20`
- 渲染:报告 ID + 时间 + 状态摘要
**验收标准**
- 按钮正确显示
- 弹窗正常弹出
- 报告列表正确渲染
- 选择后正确跳转
---
### 阶段三:前端对比结果页(预估 2h)
**任务清单**
- [ ] 3.1 新增对比结果页
- 文件:`skill/code/web/templates/service_monitor/compare.html`
- 布局:复用 report.html 框架
- 路由:`GET /service-monitor/compare/<report_id_a>/<report_id_b>`
- [ ] 3.2 差异汇总卡片
- 元素:5 个统计卡片(新增异常/恢复正常/值变化/新增项/消失项)
- 样式:不同颜色区分
- [ ] 3.3 差异明细展示
- 分组:按模块分组,可折叠
- 样式:不同差异类型使用不同颜色高亮
- 每行:KEY + 名称 + 报告A值 + 报告B值 + 变化说明
- [ ] 3.4 导出按钮
- 位置:顶部操作栏
- 选项:Markdown / HTML
- 交互:点击下载文件
**验收标准**
- 页面正常渲染
- 差异分类正确
- 高亮样式清晰
- 导出功能正常
---
### 阶段四:导出功能完善(预估 1h)
**任务清单**
- [ ] 4.1 Markdown 导出模板
- 内容:报告头 + 差异汇总 + 分类明细
- 格式:标准 Markdown
- [ ] 4.2 HTML 导出模板
- 内容:与页面布局一致
- 样式:内联 CSS(邮件发送友好)
- [ ] 4.3 文件下载处理
- 响应头:`Content-Type` / `Content-Disposition`
- 文件名:`compare_{report_a_id}_{report_b_id}.{ext}`
**验收标准**
- 导出文件格式正确
- 内容完整
- 文件名规范
---
### 阶段五:单元测试(预估 1h)
**任务清单**
- [ ] 5.1 新增测试文件
- 文件:`skill/code/tests/test_compare_service.py`
- 用例:对比逻辑、差异分类、数值变化计算
- [ ] 5.2 覆盖率目标
- 目标:`compare_service.py` > 80%
**验收标准**
- 所有测试用例通过
- 覆盖率达标
---
### 阶段六:集成测试 + 部署(预估 1h)
**任务清单**
- [ ] 6.1 本地集成测试
- 测试:完整流程(选择报告 → 对比 → 导出)
- 验证:边界情况(无差异、报告不存在、目标不匹配)
- [ ] 6.2 部署到 5.60
- 更新:`deploy/upload_to_server.py` 文件清单
- 执行:部署脚本
- 验证:健康检查 + 功能测试
- [ ] 6.3 更新文档
- 更新:`HANDOFF_服务监测.md`
- 提交:Git commit
**验收标准**
- 本地测试通过
- 部署成功
- 5.60 功能验证通过
---
## 关键文件清单
### 新增文件
| 文件 | 说明 |
|------|------|
| `service_monitor/services/compare_service.py` | 对比服务逻辑 |
| `templates/service_monitor/compare.html` | 对比结果页模板 |
| `tests/test_compare_service.py` | 单元测试 |
### 修改文件
| 文件 | 改动 |
|------|------|
| `service_monitor/routes.py` | 新增对比/导出 API 路由 |
| `templates/service_monitor/report.html` | 新增对比按钮 + 弹窗 |
| `deploy/upload_to_server.py` | 更新部署文件清单 |
---
## 风险与注意事项
### 风险 1:数值提取准确性
**问题**:部分检测项值包含单位("45%"、"100MB"),数值提取可能不准确
**缓解**
- 使用正则表达式提取数字部分
- 无法提取时标记为非数值类型,不进行值变化对比
### 风险 2:大报告对比性能
**问题**:full 套件 500+ 检测项,对比计算可能耗时
**缓解**
- 对比逻辑使用字典索引,时间复杂度 O(n)
- 限制对比项数量(按模块分组,前端懒加载)
---
## 依赖关系
- 依赖现有报告数据结构(`report_service.py`
- 依赖现有导出机制(`report_service.py` 中的导出函数)
- 无外部依赖新增
---
## 完成定义(DoD)
- [ ] 所有阶段任务完成
- [ ] 单元测试覆盖率 > 80%
- [ ] 本地集成测试通过
- [ ] 部署到 5.60 成功
- [ ] 功能验证通过
- [ ] 文档更新并提交
# PRD 需求文档:异常项趋势告警
> 模块:服务监测(service_monitor)
> 优先级:P1
> 创建日期:2026-07-23
> 状态:待开发
---
## 1. 背景与目标
### 1.1 背景
服务监测模块已实现巡检报告生成和通知推送功能。当前通知仅在巡检完成时触发一次,无法识别"同一检测项持续异常"的趋势。运维人员需要及时发现持续恶化的检测项,避免问题长期未处理。
### 1.2 目标
实现异常项趋势告警功能:
- 同一检测项连续 N 次异常时自动发送告警通知
- 支持配置连续次数阈值、告警级别、通知渠道
- 告警消息包含异常趋势信息
---
## 2. 功能需求
### 2.1 告警配置
**配置位置**:通知配置页(`notification.html`
**新增配置项**
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| 启用连续异常告警 | 开关 | 关闭 | 总开关 |
| 连续次数阈值 | 数字 | 3 | 连续 N 次异常触发告警 |
| 告警级别 | 选择 | 严重 | 仅严重 / 警告+严重 |
| 通知渠道 | 多选 | 复用现有 | 邮件/钉钉/企微(复用现有通知配置) |
**存储方式**
- 扩展现有 `notification_config` 字段,新增 `alert_on_consecutive` 子对象
- 存储在 `targets.json` 的目标配置中
### 2.2 告警检测逻辑
**触发时机**:每次巡检报告完成后
**检测流程**
1. 获取当前报告的所有异常项(按告警级别过滤)
2. 查询该目标最近 N-1 份报告(N = 配置的连续次数阈值)
3. 对每个异常项,检查是否在最近 N 份报告中连续出现
4. 连续 N 次异常的检测项触发告警
**检测函数**
```python
def check_consecutive_abnormal(target_id: str, current_report: dict) -> list:
"""
检查连续异常检测项
Args:
target_id: 目标ID
current_report: 当前报告数据
Returns:
连续异常检测项列表 [
{
"key": "CPU_USAGE",
"name": "CPU使用率",
"consecutive_count": 3,
"first_seen": "2026-07-21 10:00:00",
"last_value": "78%",
"last_status": "warning"
}
]
"""
pass
```
### 2.3 告警消息格式
**消息模板**
```
⚠️ 【连续异常告警】{目标名称}
检测项"{检测项名称}"连续 {N} 次异常,请关注!
- 检测项:{KEY} ({显示名称})
- 首次异常:{首次时间}
- 最近值:{当前值}
- 当前状态:{状态}
- 连续异常次数:{N}
查看详情:{报告链接}
```
**钉钉/企微消息格式**(Markdown):
```markdown
### ⚠️ 连续异常告警
**目标**:{目标名称}
**检测项**:{KEY} ({显示名称})
**连续异常**:{N} 次
**当前值**:{当前值}
**首次异常**:{首次时间}
[查看详情]({报告链接})
```
### 2.4 告警去重
**问题**:同一检测项连续 4、5、6...次异常时,不应每次都发告警
**去重策略**
- 同一目标 + 同一检测项,在告警触发后设置冷却期
- 冷却期 = 连续次数阈值 × 巡检间隔(默认)
- 冷却期内不重复告警
- 冷却期过后如果仍然连续异常,再次告警
**实现方式**
- 内存字典 `_alert_cooldown``{(target_id, item_key): last_alert_time}`
- 每次告警前检查冷却期
---
## 3. API 设计
### 3.1 更新通知配置
复用现有通知配置 API,扩展 `alert_on_consecutive` 字段:
```
PUT /api/service-monitor/targets/{target_id}/notification
```
**请求体新增字段**
```json
{
"alert_on_consecutive": {
"enabled": true,
"consecutive_threshold": 3,
"alert_level": "critical",
"channels": ["dingtalk", "email"]
}
}
```
### 3.2 查询连续异常状态(可选)
```
GET /api/service-monitor/targets/{target_id}/consecutive-abnormal
```
**响应**
```json
{
"success": true,
"data": {
"items": [
{
"key": "CPU_USAGE",
"name": "CPU使用率",
"consecutive_count": 3,
"first_seen": "2026-07-21 10:00:00",
"last_value": "78%",
"last_status": "warning"
}
]
}
}
```
---
## 4. 前端设计
### 4.1 通知配置页修改
**位置**`templates/service_monitor/notification.html`
**新增区域**:在现有通知配置下方新增"连续异常告警"配置区
```
┌─────────────────────────────────────────────────────────────┐
│ 连续异常告警 │
├─────────────────────────────────────────────────────────────┤
│ 启用连续异常告警 [Toggle 开关] │
│ │
│ 连续次数阈值 [3] 次 │
│ 告警级别 [仅严重 ▼] │
│ 通知渠道 [☑ 钉钉 ☑ 邮件 ☐ 企微] │
└─────────────────────────────────────────────────────────────┘
```
**交互**
- 开关关闭时,下方配置项灰显灰不可编辑
- 开关打开时,配置项可编辑
- 保存时与现有通知配置一起提交
---
## 5. 技术方案
### 5.1 后端实现
**修改文件**
- `skill/code/web/service_monitor/services/report_service.py`:新增 `_check_consecutive_abnormal()` 函数,在报告完成回调中调用
- `skill/code/web/service_monitor/services/runner_service.py`:报告完成时触发告警检查
- `skill/code/web/service_monitor/routes.py`:新增查询连续异常状态 API(可选)
**新增逻辑**
```python
# report_service.py 新增
_ALERT_COOLDOWN = {} # {(target_id, item_key): last_alert_time}
def check_consecutive_abnormal(target_id: str, current_report: dict) -> list:
"""检查连续异常检测项,返回需告警列表"""
config = _get_alert_config(target_id)
if not config or not config.get('enabled'):
return []
threshold = config.get('consecutive_threshold', 3)
alert_level = config.get('alert_level', 'critical')
# 获取当前报告异常项
abnormal_items = _get_abnormal_items(current_report, alert_level)
# 查询最近 N-1 份报告
recent_reports = _get_recent_reports(target_id, threshold - 1)
# 检查连续性
alert_items = []
for item in abnormal_items:
if _is_consecutive(item['key'], recent_reports, threshold):
# 检查冷却期
if not _in_cooldown(target_id, item['key']):
alert_items.append(item)
_set_cooldown(target_id, item['key'])
return alert_items
```
### 5.2 通知发送
复用现有通知机制:
- `runner_service.py` 已有 `_send_notification()` 函数
- 新增告警消息模板,调用现有发送函数
---
## 6. 验收标准
### 6.1 功能验收
- [ ] 通知配置页新增"连续异常告警"配置区
- [ ] 开关控制启用/禁用
- [ ] 连续次数阈值可配置
- [ ] 告警级别可配置(仅严重 / 警告+严重)
- [ ] 通知渠道复用现有配置
- [ ] 巡检完成后自动检测连续异常
- [ ] 连续 N 次异常触发告警通知
- [ ] 告警消息格式正确
- [ ] 冷却期内不重复告警
### 6.2 边界情况
- [ ] 目标无历史报告时不触发告警
- [ ] 历史报告不足 N 份时不触发告警
- [ ] 告警配置未启用时不检测
- [ ] 服务重启后冷却期重置(内存字典,可接受)
### 6.3 测试覆盖
- [ ] 单元测试:`test_consecutive_alert.py`(新增)
- [ ] 覆盖率:告警逻辑 > 80%
---
## 7. 时间规划
| 阶段 | 任务 | 预估时间 |
|------|------|---------|
| 阶段一 | 后端告警检测逻辑 | 2h |
| 阶段二 | 通知发送集成 | 1h |
| 阶段三 | 前端配置页 | 1h |
| 阶段四 | 单元测试 | 1h |
| 阶段五 | 集成测试 + 部署 | 1h |
| **合计** | | **6h** |
---
## 8. 相关文档
- `HANDOFF_服务监测.md`:模块交接文档
- `PRD_需求文档_通知配置.md`:通知配置需求
# PRD 需求文档:报告对比功能
> 模块:服务监测(service_monitor)
> 优先级:P1
> 创建日期:2026-07-23
> 状态:待开发
---
## 1. 背景与目标
### 1.1 背景
服务监测模块已实现巡检报告生成、查看、导出等功能。运维人员需要对比两次巡检结果,快速定位变化项(如新增异常、恢复正常的检测项),目前需要人工打开两份报告逐项对照,效率低下。
### 1.2 目标
实现报告对比功能:
- 支持选择两份历史报告进行对比
- 差异项分类展示(新增异常、恢复正常、值变化、新增/消失检测项)
- 支持导出对比结果
---
## 2. 功能需求
### 2.1 入口与触发
**入口位置**:报告详情页(`report.html`
**触发方式**
1. 报告详情页顶部操作栏新增"对比历史报告"按钮
2. 点击后弹出报告选择器,显示当前目标的历史报告列表(按时间倒序)
3. 选择目标报告后,跳转到对比结果页
### 2.2 报告选择器
**交互设计**
- 弹窗标题:"选择对比报告"
- 显示当前目标的历史报告列表(最多显示最近 20 份)
- 列表项格式:`报告ID(YYYY-MM-DD HH:mm)- 状态摘要`
- 支持搜索/筛选(按日期范围)
- 默认选中最近一份报告
**数据来源**
- API:`GET /api/service-monitor/reports?target_id={id}&limit=20`
- 返回报告列表(id、created_at、summary)
### 2.3 对比结果展示
**页面布局**:复用 `report.html` 框架,调整为对比视图
**差异分类**
| 类型 | 定义 | 样式 |
|------|------|------|
| 新增异常 | 报告A正常 → 报告B异常 | 红色高亮 + ⚠️ 标记 |
| 恢复正常 | 报告A异常 → 报告B正常 | 绿色高亮 + ✅ 标记 |
| 值变化 | 数值型检测项,变化幅度超过阈值 | 黄色高亮 + 📊 标记 |
| 新增检测项 | 报告A无 → 报告B有 | 蓝色 + ➕ 标记 |
| 消失检测项 | 报告A有 → 报告B无 | 灰色 + ➖ 标记 |
**值变化阈值**
- 默认变化率 > 20% 视为"值变化"
- 可配置:`VALUE_CHANGE_THRESHOLD = 0.2`
**展示层级**
1. 顶部汇总卡片:各类差异数量统计
2. 按模块分组展示差异项
3. 每个检测项显示:KEY、名称、报告A值、报告B值、变化说明
### 2.4 导出对比报告
**导出格式**
- Markdown(默认)
- HTML(带样式)
**导出内容**
- 对比报告头:两份报告 ID、时间、目标名称
- 差异汇总统计
- 按类型的差异明细列表
---
## 3. API 设计
### 3.1 对比接口
```
GET /api/service-monitor/reports/{report_id_a}/compare/{report_id_b}
```
**请求参数**
- `report_id_a`:基准报告 ID(路径参数)
- `report_id_b`:对比报告 ID(路径参数)
**响应结构**
```json
{
"success": true,
"data": {
"report_a": {
"id": "20260723_020607_63127b",
"target_name": "5.44测试服务器",
"created_at": "2026-07-23 02:06:07",
"total_items": 521,
"normal_count": 500,
"warning_count": 8,
"critical_count": 13
},
"report_b": {
"id": "20260722_140523_a1b2c3",
"target_name": "5.44测试服务器",
"created_at": "2026-07-22 14:05:23",
"total_items": 518,
"normal_count": 510,
"warning_count": 5,
"critical_count": 3
},
"diff_summary": {
"new_abnormal": 5,
"recovered": 8,
"value_changed": 12,
"new_items": 3,
"removed_items": 0
},
"diff_details": {
"new_abnormal": [
{
"key": "CPU_USAGE",
"name": "CPU使用率",
"module": "01_system_basic",
"value_a": "45%",
"value_b": "78%",
"status_a": "normal",
"status_b": "warning"
}
],
"recovered": [...],
"value_changed": [...],
"new_items": [...],
"removed_items": [...]
}
}
}
```
### 3.2 导出接口
```
GET /api/service-monitor/reports/{report_id_a}/compare/{report_id_b}/export?format=markdown|html
```
**响应**:文件下载
---
## 4. 前端设计
### 4.1 报告详情页修改
**位置**`templates/service_monitor/report.html`
**新增元素**
- 顶部操作栏新增"对比历史报告"按钮(仅在登录状态下显示,访客模式隐藏)
- 弹窗组件:报告选择器
### 4.2 对比结果页
**新增页面**`templates/service_monitor/compare.html`
**页面结构**
```
┌─────────────────────────────────────────────────────────────┐
│ 对比报告 [导出] [返回] │
├─────────────────────────────────────────────────────────────┤
│ 报告A: 20260723_02... | 报告B: 20260722_14... │
├─────────────────────────────────────────────────────────────┤
│ 差异汇总卡片 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │新增异常 5│ │恢复正常 8│ │值变化 12 │ │新增项 3 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 差异明细(按模块分组,可折叠) │
│ ▼ 01_system_basic (5项差异) │
│ ⚠️ CPU_USAGE 报告A: 45% (正常) → 报告B: 78% (警告) │
│ ✅ MEM_USAGE 报告A: 85% (警告) → 报告B: 62% (正常) │
│ 📊 DISK_USAGE 报告A: 45% → 报告B: 52% (变化+7%) │
│ ... │
└─────────────────────────────────────────────────────────────┘
```
---
## 5. 技术方案
### 5.1 后端实现
**新增文件**
- `skill/code/web/service_monitor/services/compare_service.py`:对比逻辑
**修改文件**
- `skill/code/web/service_monitor/routes.py`:新增对比 API 路由
**核心函数**
```python
# compare_service.py
def compare_reports(report_a_id: str, report_b_id: str) -> dict:
"""
对比两份报告,返回差异结果
Args:
report_a_id: 基准报告ID
report_b_id: 对比报告ID
Returns:
{
"report_a": {...},
"report_b": {...},
"diff_summary": {...},
"diff_details": {...}
}
"""
pass
def _classify_diff(item_a: dict, item_b: dict) -> str:
"""
分类单个检测项的差异类型
Returns:
"new_abnormal" | "recovered" | "value_changed" | "same"
"""
pass
def _calculate_value_change(value_a: str, value_b: str) -> float:
"""
计算数值变化率
Returns:
变化率(0.0-1.0),非数值返回 -1
"""
pass
```
### 5.2 前端实现
**修改文件**
- `templates/service_monitor/report.html`:新增对比按钮 + 弹窗
**新增文件**
- `templates/service_monitor/compare.html`:对比结果页
---
## 6. 验收标准
### 6.1 功能验收
- [ ] 报告详情页显示"对比历史报告"按钮
- [ ] 点击按钮弹出报告选择器
- [ ] 报告选择器显示当前目标历史报告列表
- [ ] 选择报告后正确跳转到对比页
- [ ] 对比页正确显示差异汇总
- [ ] 差异项按类型正确分类(新增异常/恢复正常/值变化/新增项/消失项)
- [ ] 值变化计算正确(变化率 > 20%)
- [ ] 支持导出 Markdown 格式
- [ ] 支持导出 HTML 格式
### 6.2 边界情况
- [ ] 两份报告无差异时显示友好提示
- [ ] 报告不存在时返回 404 错误
- [ ] 对比不同目标的报告时返回 400 错误
- [ ] 访客模式下隐藏对比按钮
### 6.3 测试覆盖
- [ ] 单元测试:`test_compare_service.py`(新增)
- [ ] 覆盖率:compare_service.py > 80%
---
## 7. 时间规划
| 阶段 | 任务 | 预估时间 |
|------|------|---------|
| 阶段一 | 后端对比逻辑 + API | 2h |
| 阶段二 | 前端报告选择器 | 1h |
| 阶段三 | 前端对比结果页 | 2h |
| 阶段四 | 导出功能 | 1h |
| 阶段五 | 单元测试 | 1h |
| 阶段六 | 集成测试 + 部署 | 1h |
| **合计** | | **8h** |
---
## 8. 相关文档
- `HANDOFF_服务监测.md`:模块交接文档
- `PRD_需求文档_服务监测模块.md`:模块总体需求
# HANDOFF_服务监测 — 服务监测模块:检测项丰富化移植 + 移动端响应式优化 + 功能增强规划
# HANDOFF_服务监测 — 服务监测模块:检测项丰富化移植 + 移动端响应式优化 + 功能增强(报告对比 + 连续异常告警)
> 最后更新:2026-07-23(记录下一阶段功能增强规划)| 分支:troubleshoot-ai-assistant | 负责人:czj
> 最后更新:2026-07-23(阶段九:报告对比功能 + 异常项趋势告警)| 分支:troubleshoot-ai-assistant | 负责人:czj
> 专属交接文档:聚焦服务监测模块(service_monitor)的全部历史与当前状态
---
......@@ -17,15 +17,22 @@
- **阶段五**:移植剩余 P3 级检测模块:FastDFS功能验证 + Android设备检测 + 数据备份检测 + 配置日志导出检测 + 修复能力检测
- **阶段六**:巡检防重复触发优化 + 数据备份检测超时修复
- **阶段七**:模块清理 + 5.44 验证 + 移动端响应式样式优化
- **阶段八(本次)**:移动端响应式深度优化 — 新增 480px 超小屏幕断点 + 修正布局溢出问题
- **阶段八**:移动端响应式深度优化 — 新增 480px 超小屏幕断点 + 修正布局溢出问题
- **阶段九(本次)**:功能增强 — 报告对比功能 + 异常项趋势告警
本轮会话完成了以下工作:
1. **新增 480px 断点**:9 个页面全部新增 `@media (max-width: 480px)` 超小屏幕适配,覆盖 iPhone SE 等小屏设备
2. **修正 statistics.html 筛选栏溢出**:768px 断点下筛选栏从 `flex-wrap` 改为完全纵向堆叠
3. **修正 notification.html 端口输入框溢出**`flex: 0 0 120px` 固定宽度改为自适应
4. **修正 report.html 模块标题溢出**:添加 `word-break: break-word`
5. **run.html 进度条响应式高度**:12→10→8px 三级递减
**P1:报告对比功能**
1. 新增 `compare_service.py` 对比服务,支持 5 类差异分类(新增异常/恢复正常/值变化/新增项/消失项)
2. 新增 API:`GET /api/service-monitor/reports/{id1}/compare/{id2}` 和导出接口
3. 新增 `compare.html` 对比结果页,差异按模块分组展示
4. `report.html` 新增"对比历史报告"按钮和报告选择弹窗
**P1:异常项趋势告警**
1. `report_service.py` 新增 `check_consecutive_abnormal()` 函数,检测连续异常
2. `runner_service.py` 集成连续异常告警,报告完成后自动检测并发送通知
3. `notification.html` 新增"连续异常告警"配置区(阈值/级别/渠道)
4. 冷却期机制:24 小时内同一检测项不重复告警
> **检测项丰富化移植全部完成**:阶段一至五共新增 15 个检测模块,full 套件模块数 30 → 42(移除 58 后)
>
......@@ -618,7 +625,7 @@
## 3. 当前卡在哪
**无卡点**。阶段一至全部完成。
**无卡点**。阶段一至全部完成。
**当前状态**
- 服务健康检查:status: ok, scheduler: running ✅
......@@ -649,6 +656,8 @@
- 5.44 目标凭据已配置(MySQL/Redis 密码加密注入)✅
- **移动端响应式优化完成**(9 个页面全部适配)✅
- **移动端响应式深度优化完成**(新增 480px 断点 + 修正溢出问题)✅
- **报告对比功能完成**(对比服务 + API + 页面 + 导出)✅
- **异常项趋势告警完成**(连续异常检测 + 告警通知 + 配置页)✅
---
......@@ -666,49 +675,37 @@
| ~~P2~~ | ~~在 5.44 实际运行验证新模块~~ | ✅ 已完成(10 个新模块全部正常) |
| ~~P1~~ | ~~移动端响应式优化~~ | ✅ 已完成(9 个页面全部适配) |
| ~~P1~~ | ~~移动端响应式深度优化~~ | ✅ 已完成(新增 480px 断点 + 修正溢出) |
| ~~P1~~ | ~~报告对比功能~~ | ✅ 已完成(阶段九) |
| ~~P1~~ | ~~异常项趋势告警~~ | ✅ 已完成(阶段九) |
| P2 | 导出 PDF/Excel 格式 | 待开发 |
| 持续 | 更新 HANDOFF_服务监测文档 | 每次会话结束前更新进度 |
---
## 4.1 下一阶段规划:功能增强(阶段九)
> 以下为下一轮会话的计划任务,按优先级排序
### P1:报告对比功能
**需求**:对比两次巡检结果,快速定位变化项
**功能点**
- 报告详情页新增"对比历史报告"按钮
- 选择另一份报告进行对比
- 差异展示:
- 新增异常项(原正常→现异常)
- 恢复正常项(原异常→现正常)
- 值变化项(数值对比,如 CPU 使用率 45%→78%)
- 新增/消失检测项(模块变更导致)
- 导出对比报告(Markdown/HTML)
**技术方案**
- 新增 API:`GET /api/service-monitor/reports/{id1}/compare/{id2}`
- 对比逻辑:按检测项 KEY 匹配,比对 value 和 status
- 前端:复用 report.html 布局,差异项高亮显示
## 4.1 阶段九进度:功能增强
### P1:异常项趋势告警
### ✅ P1:报告对比功能(已完成)
**需求**:同一检测项连续 N 次异常时自动发送通知
**实现内容**
- `compare_service.py`:对比服务,5 类差异分类(新增异常/恢复正常/值变化/新增项/消失项)
- API:`GET /api/service-monitor/reports/{id1}/compare/{id2}`
- 导出 API:`GET /api/service-monitor/reports/{id1}/compare/{id2}/export?format=md|html`
- `compare.html`:对比结果页,差异按模块分组展示,可折叠
- `report.html`:新增"对比历史报告"按钮 + 报告选择弹窗
- 值变化阈值:默认 > 20%
- 数值提取:正则匹配,支持带单位的值(如 45%、100MB)
**功能点**
- 通知配置页新增"连续异常告警"设置
- 连续次数阈值(默认 3 次)
- 告警级别(仅严重 / 警告+严重)
- 通知渠道(复用现有邮件/钉钉/企微)
- 检测逻辑:每次报告完成后,统计最近 N 次报告的异常项
- 告警消息模板:`⚠️ 【目标名称】检测项"XXX"连续 3 次异常,请关注`
### ✅ P1:异常项趋势告警(已完成)
**技术方案**
- 新增字段:`notification_config.alert_on_consecutive`
- `report_service.py` 新增 `_check_consecutive_abnormal()` 函数
- 在报告完成回调中触发检查
**实现内容**
- `report_service.py``check_consecutive_abnormal()` 函数
- `_get_abnormal_items()`:提取异常检测项(支持仅严重/警告+严重)
- `_is_consecutive_abnormal()`:连续性检查
- 冷却期机制:`_ALERT_COOLDOWN` 内存字典,24 小时冷却
- `runner_service.py`:集成告警检查,报告完成后自动触发
- `_send_consecutive_alert()`:告警通知发送(邮件/钉钉/企微)
- `notification.html`:新增"连续异常告警"配置区
- 配置字段:`alert_on_consecutive.enabled/threshold/alert_level/channels`
### P2:导出 PDF/Excel 格式
......@@ -834,6 +831,7 @@
| 日期 | Commit | 说明 |
|------|--------|------|
| 2026-07-23 | 待提交 | feat(service-monitor): 报告对比功能 + 异常项趋势告警 |
| 2026-07-23 | `8e7a5b3a` | style(service-monitor): 移动端响应式深度优化-新增480px超小屏幕断点 |
| 2026-07-23 | `dfe01cae` | feat(service-monitor): P3级检测模块补全+巡检防重复触发+移动端响应式优化 |
| 2026-07-22 | `664d318d` | feat(service-monitor): P3级移植-文件权限检测/定时任务预期检测 |
......@@ -937,11 +935,28 @@ feat(service-monitor): 新增安全合规/中间件检测模块并修复状态
| `templates/service_monitor/notification.html` | 修正 768px 端口输入框自适应 + 新增 480px 断点 |
| `templates/service_monitor/run.html` | 新增进度条响应式高度(12→10→8px)+ 480px 断点 |
### 本次修改文件(阶段九:报告对比 + 连续异常告警)
| 文件 | 改动 |
|------|------|
| `service_monitor/services/compare_service.py` | **新增** — 对比服务(5 类差异分类 + 导出 Markdown/HTML) |
| `service_monitor/services/report_service.py` | 新增连续异常检测函数(check_consecutive_abnormal + 冷却期) |
| `service_monitor/services/runner_service.py` | 集成连续异常告警 + 告警消息发送(邮件/钉钉/企微) |
| `service_monitor/services/__init__.py` | 导出 compare_service |
| `service_monitor/routes.py` | 新增对比/导出 API 路由 + 对比页面路由 |
| `templates/service_monitor/compare.html` | **新增** — 对比结果页模板 |
| `templates/service_monitor/report.html` | 新增"对比历史报告"按钮 + 报告选择弹窗 |
| `templates/service_monitor/notification.html` | 新增"连续异常告警"配置区 |
### 新增文档
| 文件 | 说明 |
|------|------|
| `Docs/需求文档/服务监测/PRD_需求文档_检测项丰富化移植.md` | 检测项丰富化需求文档 |
| `Docs/需求文档/服务监测/PRD_需求文档_报告对比功能.md` | 报告对比功能需求文档 |
| `Docs/需求文档/服务监测/PRD_需求文档_异常项趋势告警.md` | 异常项趋势告警需求文档 |
| `Docs/需求文档/服务监测/PRD_计划执行_报告对比功能.md` | 报告对比功能计划执行文档 |
| `Docs/需求文档/服务监测/PRD_计划执行_异常项趋势告警.md` | 异常项趋势告警计划执行文档 |
### 参考目录
......
......@@ -22,7 +22,7 @@ from flask import (
Response, redirect, url_for, stream_with_context,
)
from .services import target_service, report_service, runner_service, schedule_service, notification_service, statistics_service
from .services import target_service, report_service, runner_service, schedule_service, notification_service, statistics_service, compare_service
logger = logging.getLogger("service_monitor.routes")
......@@ -450,6 +450,83 @@ def api_export_report(report_id):
return resp
# ============================================================
# API:报告对比
# ============================================================
@bp.route('/service-monitor/compare/<report_id_a>/<report_id_b>')
def page_compare(report_id_a, report_id_b):
"""报告对比页面。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
data = compare_service.compare_reports(report_id_a, report_id_b)
if not data:
return redirect(url_for('service-monitor.page_reports'))
if data.get("error") == "targets_mismatch":
return render_template(
'service_monitor/compare.html',
user=user,
error="目标不匹配",
is_admin=(user.get('role') == 'admin'),
active_menu='reports',
)
return render_template(
'service_monitor/compare.html',
user=user,
data=data,
is_admin=(user.get('role') == 'admin'),
active_menu='reports',
)
@bp.route('/api/service-monitor/reports/<report_id_a>/compare/<report_id_b>', methods=['GET'])
def api_compare_reports(report_id_a, report_id_b):
"""对比两份报告。"""
guard = _require_login_json()
if guard:
return guard
data = compare_service.compare_reports(report_id_a, report_id_b)
if not data:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
if data.get("error") == "targets_mismatch":
return jsonify({"success": False, "error": {"code": 400, "message": "两份报告不属于同一目标"}}), 400
return jsonify({"success": True, "data": data})
@bp.route('/api/service-monitor/reports/<report_id_a>/compare/<report_id_b>/export', methods=['GET'])
def api_export_compare(report_id_a, report_id_b):
"""导出对比报告。"""
guard = _require_login_json()
if guard:
return guard
fmt = request.args.get('format', 'md')
data = compare_service.compare_reports(report_id_a, report_id_b)
if not data:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
if data.get("error") == "targets_mismatch":
return jsonify({"success": False, "error": {"code": 400, "message": "两份报告不属于同一目标"}}), 400
if fmt == 'html':
content = compare_service.export_compare_html(data)
resp = Response(content, mimetype='text/html')
resp.headers['Content-Disposition'] = f'attachment; filename=compare_{report_id_a}_{report_id_b}.html'
else:
content = compare_service.export_compare_markdown(data)
resp = Response(content, mimetype='text/markdown')
resp.headers['Content-Disposition'] = f'attachment; filename=compare_{report_id_a}_{report_id_b}.md'
return resp
# ============================================================
# 修复预留(本期不实现)
# ============================================================
......
......@@ -5,3 +5,4 @@ from . import report_service
from . import runner_service
from . import schedule_service
from . import statistics_service
from . import compare_service
# -*- coding: utf-8 -*-
"""
compare_service.py — 报告对比服务
功能:
- 对比两份巡检报告,分类差异项
- 导出对比结果(Markdown/HTML)
"""
from __future__ import annotations
import logging
import re
from typing import Dict, List, Optional, Tuple
from .report_service import get_report, NORMAL, WARNING, CRITICAL
logger = logging.getLogger("service_monitor.compare_service")
# 值变化阈值(变化率 > 20% 视为"值变化")
VALUE_CHANGE_THRESHOLD = 0.2
def compare_reports(report_id_a: str, report_id_b: str) -> Optional[Dict]:
"""对比两份报告,返回差异结果。
Args:
report_id_a: 基准报告 ID
report_id_b: 对比报告 ID
Returns:
{
"report_a": {...},
"report_b": {...},
"diff_summary": {"new_abnormal": 5, "recovered": 3, ...},
"diff_details": {"new_abnormal": [...], ...}
}
None: 任一报告不存在
{"error": "targets_mismatch"}: 目标不匹配
"""
report_a = get_report(report_id_a)
report_b = get_report(report_id_b)
if not report_a or not report_b:
return None
# 验证同一目标
if report_a.get("target_id") != report_b.get("target_id"):
return {"error": "targets_mismatch"}
# 构建检测项索引(key -> item)
items_a = _build_item_index(report_a)
items_b = _build_item_index(report_b)
# 对比
new_abnormal = []
recovered = []
value_changed = []
new_items = []
removed_items = []
# 遍历报告 B(对比报告)
for key, item_b in items_b.items():
if key not in items_a:
new_items.append(_build_diff_item(key, None, item_b, "new_item"))
continue
item_a = items_a[key]
diff_type = _classify_diff(item_a, item_b)
if diff_type == "new_abnormal":
new_abnormal.append(_build_diff_item(key, item_a, item_b, diff_type))
elif diff_type == "recovered":
recovered.append(_build_diff_item(key, item_a, item_b, diff_type))
elif diff_type == "value_changed":
value_changed.append(_build_diff_item(key, item_a, item_b, diff_type))
# 遍历报告 A,找出消失的检测项
for key, item_a in items_a.items():
if key not in items_b:
removed_items.append(_build_diff_item(key, item_a, None, "removed_item"))
# 按模块分组
diff_details = {
"new_abnormal": _group_by_module(new_abnormal),
"recovered": _group_by_module(recovered),
"value_changed": _group_by_module(value_changed),
"new_items": _group_by_module(new_items),
"removed_items": _group_by_module(removed_items),
}
diff_summary = {
"new_abnormal": len(new_abnormal),
"recovered": len(recovered),
"value_changed": len(value_changed),
"new_items": len(new_items),
"removed_items": len(removed_items),
}
return {
"report_a": _report_summary(report_a),
"report_b": _report_summary(report_b),
"diff_summary": diff_summary,
"diff_details": diff_details,
}
def export_compare_markdown(data: Dict) -> str:
"""导出对比结果为 Markdown。"""
ra = data.get("report_a", {})
rb = data.get("report_b", {})
summary = data.get("diff_summary", {})
details = data.get("diff_details", {})
lines = [
"# 巡检报告对比",
"",
"| | 基准报告 | 对比报告 |",
"|---|---------|---------|",
f"| 报告ID | {ra.get('id', '-')} | {rb.get('id', '-')} |",
f"| 目标 | {ra.get('target_name', '-')} | {rb.get('target_name', '-')} |",
f"| 时间 | {ra.get('created_at', '-')} | {rb.get('created_at', '-')} |",
f"| 总项数 | {ra.get('total_items', '-')} | {rb.get('total_items', '-')} |",
f"| 正常 | {ra.get('normal_count', '-')} | {rb.get('normal_count', '-')} |",
f"| 警告 | {ra.get('warning_count', '-')} | {rb.get('warning_count', '-')} |",
f"| 严重 | {ra.get('critical_count', '-')} | {rb.get('critical_count', '-')} |",
"",
"## 差异汇总",
"",
"| 类型 | 数量 |",
"|------|------|",
f"| 新增异常 | {summary.get('new_abnormal', 0)} |",
f"| 恢复正常 | {summary.get('recovered', 0)} |",
f"| 值变化 | {summary.get('value_changed', 0)} |",
f"| 新增检测项 | {summary.get('new_items', 0)} |",
f"| 消失检测项 | {summary.get('removed_items', 0)} |",
"",
]
# 各类差异明细
type_labels = {
"new_abnormal": ("新增异常", "warning"),
"recovered": ("恢复正常", "recovered"),
"value_changed": ("值变化", "changed"),
"new_items": ("新增检测项", "new"),
"removed_items": ("消失检测项", "removed"),
}
for diff_type, (label, _) in type_labels.items():
items = details.get(diff_type, [])
if not items:
continue
lines += [f"## {label}", ""]
for group in items:
lines += [f"### {group.get('module_name', '未知模块')}", ""]
lines += ["| 检测项 | 基准值 | 对比值 | 变化 |",
"|--------|--------|--------|------|"]
for it in group.get("items", []):
change_desc = it.get("change_desc", "-")
lines.append(
f"| {it.get('name', it.get('key', '-'))} "
f"| {it.get('value_a', '-')} "
f"| {it.get('value_b', '-')} "
f"| {change_desc} |"
)
lines.append("")
return "\n".join(lines)
def export_compare_html(data: Dict) -> str:
"""导出对比结果为 HTML(内联样式,适合邮件/打印)。"""
ra = data.get("report_a", {})
rb = data.get("report_b", {})
summary = data.get("diff_summary", {})
details = data.get("diff_details", {})
html_parts = [
"<!DOCTYPE html><html><head><meta charset='utf-8'>",
"<title>巡检报告对比</title>",
"<style>",
"body{font-family:'Microsoft YaHei',sans-serif;max-width:960px;margin:0 auto;padding:20px;color:#333}",
"h1{color:#1a73e8;border-bottom:2px solid #1a73e8;padding-bottom:8px}",
"h2{color:#333;margin-top:24px}",
"h3{color:#555}",
"table{border-collapse:collapse;width:100%;margin:8px 0}",
"th,td{border:1px solid #ddd;padding:8px 12px;text-align:left}",
"th{background:#f5f5f5;font-weight:600}",
".summary-cards{display:flex;gap:12px;margin:16px 0}",
".card{flex:1;padding:12px;border-radius:8px;text-align:center}",
".card-abnormal{background:#fff0f0;border:1px solid #ffccc7}",
".card-recovered{background:#f0fff0;border:1px solid #b7eb8f}",
".card-changed{background:#fffbe6;border:1px solid #ffe58f}",
".card-new{background:#e6f7ff;border:1px solid #91d5ff}",
".card-removed{background:#f5f5f5;border:1px solid #d9d9d9}",
".card .count{font-size:28px;font-weight:700}",
".card .label{font-size:13px;color:#666}",
".tag-abnormal{color:#cf1322;font-weight:600}",
".tag-recovered{color:#389e0d;font-weight:600}",
".tag-changed{color:#d48806;font-weight:600}",
".tag-new{color:#096dd9;font-weight:600}",
".tag-removed{color:#8c8c8c}",
"</style></head><body>",
"<h1>巡检报告对比</h1>",
_html_report_table(ra, rb),
_html_summary_cards(summary),
]
type_labels = {
"new_abnormal": ("新增异常", "abnormal"),
"recovered": ("恢复正常", "recovered"),
"value_changed": ("值变化", "changed"),
"new_items": ("新增检测项", "new"),
"removed_items": ("消失检测项", "removed"),
}
for diff_type, (label, css_cls) in type_labels.items():
items = details.get(diff_type, [])
if not items:
continue
html_parts.append(f"<h2>{label}</h2>")
for group in items:
html_parts.append(f"<h3>{group.get('module_name', '未知模块')}</h3>")
html_parts.append("<table><tr><th>检测项</th><th>基准值</th><th>对比值</th><th>变化</th></tr>")
for it in group.get("items", []):
change_desc = it.get("change_desc", "-")
html_parts.append(
f"<tr>"
f"<td class='tag-{css_cls}'>{it.get('name', it.get('key', '-'))}</td>"
f"<td>{it.get('value_a', '-')}</td>"
f"<td>{it.get('value_b', '-')}</td>"
f"<td>{change_desc}</td>"
f"</tr>"
)
html_parts.append("</table>")
html_parts.append("</body></html>")
return "\n".join(html_parts)
# ============================================================
# 内部辅助函数
# ============================================================
def _build_item_index(report: Dict) -> Dict[str, Dict]:
"""构建检测项索引:key -> {key, name, value, status, module_id, module_name}"""
index = {}
for m in report.get("modules", []):
module_id = m.get("id", "")
module_name = m.get("name", "")
for it in m.get("items", []):
key = it.get("key", "")
if key:
index[key] = {
"key": key,
"name": it.get("name", key),
"value": it.get("value", ""),
"status": it.get("status", NORMAL),
"module_id": module_id,
"module_name": module_name,
}
return index
def _classify_diff(item_a: Dict, item_b: Dict) -> str:
"""分类单个检测项的差异类型。
Returns:
"new_abnormal" | "recovered" | "value_changed" | "same"
"""
status_a = item_a.get("status", NORMAL)
status_b = item_b.get("status", NORMAL)
is_abnormal_a = status_a in (WARNING, CRITICAL)
is_abnormal_b = status_b in (WARNING, CRITICAL)
# 状态变化
if not is_abnormal_a and is_abnormal_b:
return "new_abnormal"
if is_abnormal_a and not is_abnormal_b:
return "recovered"
# 值变化(仅数值型,状态相同时)
if status_a == status_b:
change_rate = _calculate_value_change(item_a.get("value", ""), item_b.get("value", ""))
if change_rate >= 0 and change_rate > VALUE_CHANGE_THRESHOLD:
return "value_changed"
return "same"
def _calculate_value_change(value_a: str, value_b: str) -> float:
"""计算数值变化率。
Returns:
变化率(0.0~1.0+),非数值返回 -1
"""
num_a = _extract_numeric(value_a)
num_b = _extract_numeric(value_b)
if num_a is None or num_b is None:
return -1.0
if num_a == 0 and num_b == 0:
return 0.0
if num_a == 0:
return 1.0 # 0 → 非零,视为 100% 变化
return abs(num_b - num_a) / abs(num_a)
_NUM_RE = re.compile(r"[-+]?\d*\.?\d+")
def _extract_numeric(value: str) -> Optional[float]:
"""从值字符串中提取数值。"""
if not value:
return None
m = _NUM_RE.search(str(value))
if m:
try:
return float(m.group())
except (ValueError, TypeError):
return None
return None
def _build_diff_item(key: str, item_a: Optional[Dict], item_b: Optional[Dict],
diff_type: str) -> Dict:
"""构建差异项。"""
result = {
"key": key,
"name": (item_b or item_a or {}).get("name", key),
"module_id": (item_b or item_a or {}).get("module_id", ""),
"module_name": (item_b or item_a or {}).get("module_name", ""),
"diff_type": diff_type,
"value_a": item_a.get("value", "-") if item_a else "-",
"value_b": item_b.get("value", "-") if item_b else "-",
"status_a": item_a.get("status", "-") if item_a else "-",
"status_b": item_b.get("status", "-") if item_b else "-",
}
# 变化说明
result["change_desc"] = _describe_change(result)
return result
def _describe_change(item: Dict) -> str:
"""生成变化说明文字。"""
diff_type = item.get("diff_type", "")
if diff_type == "new_abnormal":
return f"{item.get('status_a', '-')} -> {item.get('status_b', '-')}"
if diff_type == "recovered":
return f"{item.get('status_a', '-')} -> {item.get('status_b', '-')}"
if diff_type == "value_changed":
val_a = item.get("value_a", "-")
val_b = item.get("value_b", "-")
num_a = _extract_numeric(str(val_a))
num_b = _extract_numeric(str(val_b))
if num_a is not None and num_b is not None and num_a != 0:
pct = ((num_b - num_a) / abs(num_a)) * 100
sign = "+" if pct > 0 else ""
return f"{val_a} -> {val_b} ({sign}{pct:.1f}%)"
return f"{val_a} -> {val_b}"
if diff_type == "new_item":
return "新增检测项"
if diff_type == "removed_item":
return "消失检测项"
return "-"
def _group_by_module(items: List[Dict]) -> List[Dict]:
"""按模块分组差异项。"""
groups = {}
for it in items:
mod = it.get("module_id", "unknown")
if mod not in groups:
groups[mod] = {
"module_id": mod,
"module_name": it.get("module_name", "未知模块"),
"items": [],
}
groups[mod]["items"].append(it)
return list(groups.values())
def _report_summary(report: Dict) -> Dict:
"""提取报告摘要信息。"""
s = report.get("summary", {})
return {
"id": report.get("id", ""),
"target_id": report.get("target_id", ""),
"target_name": report.get("target_name", ""),
"target_type": report.get("target_type", ""),
"suite": report.get("suite", ""),
"created_at": report.get("finished_at") or report.get("started_at", ""),
"total_items": s.get("total", 0),
"normal_count": s.get(NORMAL, 0),
"warning_count": s.get(WARNING, 0),
"critical_count": s.get(CRITICAL, 0),
}
def _html_report_table(ra: Dict, rb: Dict) -> str:
"""生成报告信息对比 HTML 表格。"""
return (
"<table>"
"<tr><th></th><th>基准报告</th><th>对比报告</th></tr>"
f"<tr><td>报告ID</td><td>{ra.get('id','-')}</td><td>{rb.get('id','-')}</td></tr>"
f"<tr><td>目标</td><td>{ra.get('target_name','-')}</td><td>{rb.get('target_name','-')}</td></tr>"
f"<tr><td>时间</td><td>{ra.get('created_at','-')}</td><td>{rb.get('created_at','-')}</td></tr>"
f"<tr><td>总项数</td><td>{ra.get('total_items','-')}</td><td>{rb.get('total_items','-')}</td></tr>"
f"<tr><td>正常</td><td>{ra.get('normal_count','-')}</td><td>{rb.get('normal_count','-')}</td></tr>"
f"<tr><td>警告</td><td>{ra.get('warning_count','-')}</td><td>{rb.get('warning_count','-')}</td></tr>"
f"<tr><td>严重</td><td>{ra.get('critical_count','-')}</td><td>{rb.get('critical_count','-')}</td></tr>"
"</table>"
)
def _html_summary_cards(summary: Dict) -> str:
"""生成差异汇总 HTML 卡片。"""
cards = [
("card-abnormal", "新增异常", summary.get("new_abnormal", 0)),
("card-recovered", "恢复正常", summary.get("recovered", 0)),
("card-changed", "值变化", summary.get("value_changed", 0)),
("card-new", "新增检测项", summary.get("new_items", 0)),
("card-removed", "消失检测项", summary.get("removed_items", 0)),
]
parts = ['<div class="summary-cards">']
for css_cls, label, count in cards:
parts.append(
f'<div class="card {css_cls}">'
f'<div class="count">{count}</div>'
f'<div class="label">{label}</div>'
f'</div>'
)
parts.append("</div>")
return "\n".join(parts)
......@@ -264,3 +264,158 @@ def validate_access_token(report_id: str, token: str) -> bool:
return False
return True
# ============================================================
# 连续异常告警
# ============================================================
from typing import List as _List
# 冷却期记录:{(target_id, item_key): last_alert_time}
_ALERT_COOLDOWN: Dict[str, str] = {}
def check_consecutive_abnormal(target_id: str, current_report: dict) -> _List[dict]:
"""检查连续异常检测项,返回需告警列表。
Args:
target_id: 目标 ID
current_report: 当前报告数据
Returns:
连续异常检测项列表 [
{
"key": "CPU_USAGE",
"name": "CPU使用率",
"consecutive_count": 3,
"first_seen": "2026-07-21T10:00:00",
"last_value": "78%",
"last_status": "warning"
}
]
"""
# 获取告警配置
from . import notification_service
config = notification_service.get_config()
alert_config = config.get("alert_on_consecutive", {})
if not alert_config.get("enabled"):
return []
threshold = alert_config.get("consecutive_threshold", 3)
alert_level = alert_config.get("alert_level", "critical") # "critical" or "all"
# 提取当前报告异常项
current_abnormal = _get_abnormal_items(current_report, alert_level)
if not current_abnormal:
return []
# 查询最近 N-1 份报告
recent_reports = list_reports(target_id=target_id, limit=threshold)
# 排除当前报告
current_id = current_report.get("id", "")
recent_reports = [r for r in recent_reports if r.get("id") != current_id]
if len(recent_reports) < threshold - 1:
# 历史报告不足,无法判断连续性
return []
# 检查连续性
alert_items = []
for item in current_abnormal:
key = item.get("key", "")
if _is_consecutive_abnormal(key, recent_reports, threshold - 1, alert_level):
# 检查冷却期
cooldown_key = f"{target_id}:{key}"
if not _in_cooldown(cooldown_key):
# 查找首次异常时间
first_seen = _find_first_abnormal_time(key, recent_reports, threshold - 1)
item["consecutive_count"] = threshold
item["first_seen"] = first_seen
alert_items.append(item)
_set_cooldown(cooldown_key)
return alert_items
def _get_abnormal_items(report: dict, alert_level: str) -> _List[dict]:
"""提取报告中的异常检测项。"""
items = []
for mod in report.get("modules", []):
for it in mod.get("items", []):
status = it.get("status", "")
if alert_level == "critical" and status == CRITICAL:
items.append({
"key": it.get("key", ""),
"name": it.get("name", ""),
"value": it.get("value", ""),
"status": status,
"module_name": mod.get("name", ""),
})
elif alert_level == "all" and status in (WARNING, CRITICAL):
items.append({
"key": it.get("key", ""),
"name": it.get("name", ""),
"value": it.get("value", ""),
"status": status,
"module_name": mod.get("name", ""),
})
return items
def _is_consecutive_abnormal(item_key: str, recent_reports: _List[dict],
required_count: int, alert_level: str) -> bool:
"""检查检测项是否连续异常。"""
count = 0
for report_summary in recent_reports[:required_count]:
report_id = report_summary.get("id", "")
if not report_id:
continue
report = get_report(report_id)
if not report:
continue
# 检查该检测项是否异常
abnormal_items = _get_abnormal_items(report, alert_level)
if any(it.get("key") == item_key for it in abnormal_items):
count += 1
else:
break # 非连续
return count >= required_count
def _find_first_abnormal_time(item_key: str, recent_reports: _List[dict],
count: int) -> str:
"""查找首次异常时间(从最近第 N 份报告)。"""
for report_summary in reversed(recent_reports[:count]):
report_id = report_summary.get("id", "")
if not report_id:
continue
report = get_report(report_id)
if not report:
continue
abnormal_items = _get_abnormal_items(report, "all") # 任意级别
if any(it.get("key") == item_key for it in abnormal_items):
return report.get("finished_at") or report.get("started_at", "")
return ""
def _in_cooldown(cooldown_key: str) -> bool:
"""检查是否在冷却期内。"""
if cooldown_key not in _ALERT_COOLDOWN:
return False
last_alert = _ALERT_COOLDOWN[cooldown_key]
# 冷却期 24 小时
try:
from datetime import timedelta
last_dt = datetime.strptime(last_alert[:19], "%Y-%m-%dT%H:%M:%S")
if datetime.now() - last_dt < timedelta(hours=24):
return True
except (ValueError, TypeError):
pass
return False
def _set_cooldown(cooldown_key: str) -> None:
"""设置冷却期。"""
_ALERT_COOLDOWN[cooldown_key] = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
......@@ -13,10 +13,13 @@ runner_service.py — 巡检执行编排
from __future__ import annotations
import hashlib
import hmac
import logging
import threading
import time
import traceback
import urllib.parse
from datetime import datetime
from typing import Dict, Optional
......@@ -302,6 +305,14 @@ def run_inspection_sync(target_id: str, suite: str) -> dict:
if report.get("access_token"):
report_url = f"{report_url}?token={report['access_token']}"
notification_service.send_notification(report, report_url)
# 连续异常告警检查
try:
alert_items = report_service.check_consecutive_abnormal(target_id, report)
if alert_items:
_send_consecutive_alert(target, alert_items, report_url, report)
except Exception as e:
logger.warning("连续异常告警检查失败: %s", e)
except Exception as e:
logger.warning("发送通知失败: %s", e)
......@@ -322,3 +333,174 @@ def run_inspection_sync(target_id: str, suite: str) -> dict:
executor.cleanup()
except Exception as e:
logger.warning("执行器清理失败: %s", e)
def _send_consecutive_alert(target: dict, alert_items: list,
report_url: str, report: dict) -> None:
"""发送连续异常告警通知。"""
from . import notification_service
config = notification_service.get_config()
alert_config = config.get("alert_on_consecutive", {})
channels = alert_config.get("channels", [])
target_name = target.get("name", "未知目标")
# 构建告警消息
for item in alert_items:
key = item.get("key", "")
name = item.get("name", key)
count = item.get("consecutive_count", 3)
value = item.get("value", "-")
status = item.get("status", "-")
first_seen = item.get("first_seen", "-")
if first_seen:
first_seen = first_seen.replace("T", " ")
# Markdown 消息(钉钉/企微)
md_msg = (
f"### ⚠️ 连续异常告警\n\n"
f"**目标**:{target_name}\n\n"
f"**检测项**:{key} ({name})\n\n"
f"**连续异常**:{count} 次\n\n"
f"**当前值**:{value}\n\n"
f"**首次异常**:{first_seen}\n\n"
f"[查看详情]({report_url})"
)
# 发送邮件
if "email" in channels and config.get("email", {}).get("enabled"):
try:
_send_alert_email(config, target_name, name, count, value,
status, first_seen, report_url)
except Exception as e:
logger.warning("连续异常告警邮件发送失败: %s", e)
# 发送钉钉
if "dingtalk" in channels and config.get("dingtalk", {}).get("enabled"):
try:
_send_alert_dingtalk(config, md_msg, target_name)
except Exception as e:
logger.warning("连续异常告警钉钉发送失败: %s", e)
# 发送企微
if "wecom" in channels and config.get("wecom", {}).get("enabled"):
try:
_send_alert_wecom(config, md_msg)
except Exception as e:
logger.warning("连续异常告警企微发送失败: %s", e)
logger.info("连续异常告警已发送: %s, %d 项", target_name, len(alert_items))
def _send_alert_email(config: dict, target_name: str, item_name: str,
count: int, value: str, status: str,
first_seen: str, report_url: str) -> None:
"""发送连续异常告警邮件。"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
email_cfg = config.get("email", {})
smtp_host = email_cfg.get("smtp_host", "").strip()
smtp_port = int(email_cfg.get("smtp_port", 465))
smtp_user = email_cfg.get("smtp_user", "").strip()
smtp_password = email_cfg.get("smtp_password", "").strip()
use_ssl = email_cfg.get("use_ssl", True)
recipients = email_cfg.get("recipients", [])
if not all([smtp_host, smtp_user, smtp_password, recipients]):
return
subject = f"【连续异常告警】{target_name} - {item_name} 连续 {count} 次异常"
body = (
f"<h2>⚠️ 连续异常告警</h2>"
f"<p><b>目标:</b>{target_name}</p>"
f"<p><b>检测项:</b>{item_name}</p>"
f"<p><b>连续异常次数:</b>{count}</p>"
f"<p><b>当前值:</b>{value}</p>"
f"<p><b>当前状态:</b>{status}</p>"
f"<p><b>首次异常:</b>{first_seen}</p>"
f"<hr><p><a href='{report_url}'>查看详情</a></p>"
f"<p style='color:#666;font-size:12px;'>此邮件由服务监测模块自动发送</p>"
)
msg = MIMEMultipart()
msg["From"] = smtp_user
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
msg.attach(MIMEText(body, "html", "utf-8"))
if use_ssl:
smtp = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10)
else:
smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
smtp.starttls()
smtp.login(smtp_user, smtp_password)
smtp.sendmail(smtp_user, recipients, msg.as_string())
smtp.quit()
def _send_alert_dingtalk(config: dict, md_msg: str, target_name: str) -> None:
"""发送连续异常告警钉钉消息。"""
import requests
import base64
ding_cfg = config.get("dingtalk", {})
webhook_url = ding_cfg.get("webhook_url", "").strip()
secret = ding_cfg.get("secret", "").strip()
if not webhook_url:
return
content = {
"msgtype": "markdown",
"markdown": {
"title": f"连续异常告警 - {target_name}",
"text": md_msg
}
}
url = webhook_url
if secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code).decode())
url = f"{webhook_url}&timestamp={timestamp}&sign={sign}"
resp = requests.post(url, json=content, timeout=10)
if resp.status_code == 200:
result = resp.json()
if result.get("errcode") != 0:
logger.warning("钉钉告警发送失败: %s", result.get("errmsg"))
else:
logger.warning("钉钉告警请求失败: HTTP %d", resp.status_code)
def _send_alert_wecom(config: dict, md_msg: str) -> None:
"""发送连续异常告警企微消息。"""
import requests
wecom_cfg = config.get("wecom", {})
webhook = wecom_cfg.get("webhook", "").strip()
if not webhook:
return
content = {
"msgtype": "markdown",
"markdown": {"content": md_msg}
}
resp = requests.post(webhook, json=content, timeout=10)
if resp.status_code == 200:
result = resp.json()
if result.get("errcode") != 0:
logger.warning("企微告警发送失败: %s", result.get("errmsg"))
else:
logger.warning("企微告警请求失败: HTTP %d", resp.status_code)
{% extends "service_monitor/base.html" %}
{% block title %}报告对比{% endblock %}
{% block extra_css %}
<style>
.container { max-width: 960px; }
/* 对比报告头 */
.compare-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 12px; }
.compare-meta h2 { font-size: 20px; color: var(--gray-900); margin-bottom: 4px; }
.compare-meta .sub { font-size: 13px; color: var(--gray-500); }
.compare-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.btn-action { border: none; padding: 9px 18px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; text-decoration: none; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { background: var(--gray-100); color: var(--gray-700); border: 1px solid var(--gray-200); }
.btn-secondary:hover { background: var(--gray-200); }
/* 报告信息对比表 */
.report-info-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; background: #fff; border-radius: 12px; overflow: hidden; border: 1px solid var(--gray-200); }
.report-info-table th { background: var(--gray-50); padding: 10px 16px; text-align: left; font-weight: 600; color: var(--gray-700); border-bottom: 1px solid var(--gray-200); }
.report-info-table td { padding: 10px 16px; border-bottom: 1px solid var(--gray-100); font-size: 14px; color: var(--gray-900); }
.report-info-table tr:last-child td { border-bottom: none; }
.report-info-table .col-label { width: 100px; color: var(--gray-500); font-weight: 500; }
/* 差异汇总卡片 */
.diff-summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }
.diff-card { background: #fff; border-radius: 12px; padding: 18px; text-align: center; border: 1px solid var(--gray-200); }
.diff-card .count { font-size: 28px; font-weight: 700; }
.diff-card .label { font-size: 13px; color: var(--gray-500); margin-top: 4px; }
.diff-card .icon { font-size: 20px; margin-bottom: 4px; }
.dc-abnormal { border-color: #ffccc7; background: #fff0f0; }
.dc-abnormal .count { color: #cf1322; }
.dc-recovered { border-color: #b7eb8f; background: #f0fff0; }
.dc-recovered .count { color: #389e0d; }
.dc-changed { border-color: #ffe58f; background: #fffbe6; }
.dc-changed .count { color: #d48806; }
.dc-new { border-color: #91d5ff; background: #e6f7ff; }
.dc-new .count { color: #096dd9; }
.dc-removed { border-color: #d9d9d9; background: #f5f5f5; }
.dc-removed .count { color: #8c8c8c; }
/* 差异类型区块 */
.diff-section { margin-bottom: 20px; }
.diff-section-title { font-size: 16px; font-weight: 700; color: var(--gray-900); margin: 0 0 12px; display: flex; align-items: center; gap: 8px; padding-bottom: 8px; border-bottom: 2px solid var(--gray-200); }
.diff-section-title .badge { font-size: 12px; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
.badge-abnormal { background: #fff0f0; color: #cf1322; }
.badge-recovered { background: #f0fff0; color: #389e0d; }
.badge-changed { background: #fffbe6; color: #d48806; }
.badge-new { background: #e6f7ff; color: #096dd9; }
.badge-removed { background: #f5f5f5; color: #8c8c8c; }
/* 模块分组 */
.module-group { background: #fff; border-radius: 12px; border: 1px solid var(--gray-200); margin-bottom: 10px; overflow: hidden; }
.module-group-header { display: flex; align-items: center; gap: 10px; padding: 12px 18px; cursor: pointer; user-select: none; }
.module-group-header:hover { background: var(--gray-50); }
.module-group-toggle { font-size: 12px; color: var(--gray-500); transition: transform .2s; flex-shrink: 0; }
.module-group.open .module-group-toggle { transform: rotate(90deg); }
.module-group-name { font-weight: 700; color: var(--gray-900); flex: 1; }
.module-group-count { font-size: 12px; color: var(--gray-500); }
.module-group-body { max-height: 0; overflow: hidden; transition: max-height .35s ease; }
.module-group.open .module-group-body { max-height: 5000px; }
/* 差异项行 */
.diff-row { display: grid; grid-template-columns: 2fr 1.2fr 1.2fr 1.5fr; gap: 12px; padding: 10px 18px; border-top: 1px solid var(--gray-100); font-size: 14px; align-items: center; }
.diff-row:last-child { border-top: none; }
.diff-item-name { font-weight: 600; color: var(--gray-900); }
.diff-value { font-weight: 500; }
.diff-change { font-size: 13px; }
.tag-abnormal { color: #cf1322; font-weight: 600; }
.tag-recovered { color: #389e0d; font-weight: 600; }
.tag-changed { color: #d48806; font-weight: 600; }
.tag-new { color: #096dd9; font-weight: 600; }
.tag-removed { color: #8c8c8c; }
/* 无差异提示 */
.no-diff { text-align: center; padding: 40px 20px; color: var(--gray-500); font-size: 15px; }
.no-diff .icon { font-size: 48px; margin-bottom: 12px; }
/* 错误提示 */
.error-box { text-align: center; padding: 40px 20px; }
.error-box .icon { font-size: 48px; margin-bottom: 12px; }
.error-box .msg { color: var(--red); font-size: 16px; font-weight: 600; }
/* 导出菜单 */
.export-wrap { position: relative; }
.export-menu { display: none; position: absolute; right: 0; top: 44px; background: #fff; border: 1px solid var(--gray-200); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.12); overflow: hidden; z-index: 10; }
.export-menu.show { display: block; }
.export-menu a { display: block; padding: 10px 20px; font-size: 14px; color: var(--gray-700); text-decoration: none; }
.export-menu a:hover { background: var(--gray-50); }
@media screen and (max-width: 768px) {
.container { padding: 0; }
.compare-head { flex-direction: column; align-items: flex-start; gap: 10px; }
.compare-meta h2 { font-size: 16px; }
.compare-actions { width: 100%; }
.btn-action { flex: 1; text-align: center; font-size: 13px; padding: 8px 12px; }
.diff-summary { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.diff-card { padding: 12px; }
.diff-card .count { font-size: 22px; }
.diff-card .label { font-size: 11px; }
.report-info-table td, .report-info-table th { padding: 8px 10px; font-size: 13px; }
.diff-row { grid-template-columns: 1fr 1fr; gap: 4px; padding: 8px 14px; font-size: 13px; }
.diff-row .diff-value:last-of-type { display: none; }
.module-group-header { padding: 10px 14px; }
.module-group-name { font-size: 14px; word-break: break-word; }
.export-menu { position: fixed; left: 12px; right: 12px; top: auto; bottom: 12px; border-radius: 12px; }
}
@media screen and (max-width: 480px) {
.compare-meta h2 { font-size: 14px; }
.diff-summary { grid-template-columns: repeat(2, 1fr); gap: 6px; }
.diff-card { padding: 10px; }
.diff-card .count { font-size: 18px; }
.diff-card .icon { font-size: 16px; }
.diff-row { padding: 7px 12px; font-size: 12px; }
.module-group-header { padding: 8px 12px; }
.module-group-name { font-size: 13px; }
.btn-action { font-size: 12px; padding: 7px 10px; }
}
</style>
{% endblock %}
{% block content %}
<div class="container">
{% if error %}
<div class="error-box">
<div class="icon"></div>
<div class="msg">{{ error }}</div>
<a href="/service-monitor/reports" class="btn-action btn-secondary" style="margin-top:16px;">返回报告列表</a>
</div>
{% elif data %}
<div class="compare-head">
<div class="compare-meta">
<h2>📊 报告对比</h2>
<div class="sub">
{{ data.report_a.target_name }} ·
{{ (data.report_a.created_at or '').replace('T', ' ') }} vs {{ (data.report_b.created_at or '').replace('T', ' ') }}
</div>
</div>
<div class="compare-actions">
<div class="export-wrap">
<button class="btn-action btn-primary" onclick="toggleExport()">⬇ 导出对比 ▾</button>
<div class="export-menu" id="export-menu">
<a href="/api/service-monitor/reports/{{ data.report_a.id }}/compare/{{ data.report_b.id }}/export?format=md">导出 Markdown</a>
<a href="/api/service-monitor/reports/{{ data.report_a.id }}/compare/{{ data.report_b.id }}/export?format=html">导出 HTML</a>
</div>
</div>
<a href="/service-monitor/report/{{ data.report_a.id }}" class="btn-action btn-secondary">查看基准报告</a>
<a href="/service-monitor/reports" class="btn-action btn-secondary">返回列表</a>
</div>
</div>
<!-- 报告信息对比表 -->
<table class="report-info-table">
<tr><th></th><th>基准报告</th><th>对比报告</th></tr>
<tr><td class="col-label">报告ID</td><td>{{ data.report_a.id }}</td><td>{{ data.report_b.id }}</td></tr>
<tr><td class="col-label">时间</td><td>{{ (data.report_a.created_at or '').replace('T', ' ') }}</td><td>{{ (data.report_b.created_at or '').replace('T', ' ') }}</td></tr>
<tr><td class="col-label">总项数</td><td>{{ data.report_a.total_items }}</td><td>{{ data.report_b.total_items }}</td></tr>
<tr><td class="col-label">正常</td><td>{{ data.report_a.normal_count }}</td><td>{{ data.report_b.normal_count }}</td></tr>
<tr><td class="col-label">警告</td><td>{{ data.report_a.warning_count }}</td><td>{{ data.report_b.warning_count }}</td></tr>
<tr><td class="col-label">严重</td><td>{{ data.report_a.critical_count }}</td><td>{{ data.report_b.critical_count }}</td></tr>
</table>
<!-- 差异汇总卡片 -->
<div class="diff-summary">
<div class="diff-card dc-abnormal">
<div class="icon">⚠️</div>
<div class="count">{{ data.diff_summary.new_abnormal }}</div>
<div class="label">新增异常</div>
</div>
<div class="diff-card dc-recovered">
<div class="icon"></div>
<div class="count">{{ data.diff_summary.recovered }}</div>
<div class="label">恢复正常</div>
</div>
<div class="diff-card dc-changed">
<div class="icon">📊</div>
<div class="count">{{ data.diff_summary.value_changed }}</div>
<div class="label">值变化</div>
</div>
<div class="diff-card dc-new">
<div class="icon"></div>
<div class="count">{{ data.diff_summary.new_items }}</div>
<div class="label">新增检测项</div>
</div>
<div class="diff-card dc-removed">
<div class="icon"></div>
<div class="count">{{ data.diff_summary.removed_items }}</div>
<div class="label">消失检测项</div>
</div>
</div>
{% set total_diffs = data.diff_summary.new_abnormal + data.diff_summary.recovered + data.diff_summary.value_changed + data.diff_summary.new_items + data.diff_summary.removed_items %}
{% if total_diffs == 0 %}
<div class="no-diff">
<div class="icon">🎉</div>
<div>两份报告无差异,所有检测项状态一致</div>
</div>
{% else %}
<!-- 新增异常 -->
{% if data.diff_details.new_abnormal %}
<div class="diff-section">
<div class="diff-section-title">⚠️ 新增异常 <span class="badge badge-abnormal">{{ data.diff_summary.new_abnormal }}</span></div>
{% for group in data.diff_details.new_abnormal %}
<div class="module-group open">
<div class="module-group-header" onclick="this.parentElement.classList.toggle('open')">
<span class="module-group-toggle"></span>
<span class="module-group-name">{{ group.module_name }}</span>
<span class="module-group-count">{{ group.items|length }} 项</span>
</div>
<div class="module-group-body">
">
{% for it in group.items %}
<div class="diff-row">
<div class="diff-item-name tag-abnormal">{{ it.name }}</div>
<div class="diff-value">{{ it.value_a }}</div>
<div class="diff-value tag-abnormal">{{ it.value_b }}</div>
<div class="diff-change tag-abnormal">{{ it.change_desc }}</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- 恢复正常 -->
{% if data.diff_details.recovered %}
<div class="diff-section">
<div class="diff-section-title">✅ 恢复正常 <span class="badge badge-recovered">{{ data.diff_summary.recovered }}</span></div>
{% for group in data.diff_details.recovered %}
<div class="module-group open">
<div class="module-group-header" onclick="this.parentElement.classList.toggle('open')">
<span class="module-group-toggle"></span>
<span class="module-group-name">{{ group.module_name }}</span>
<span class="module-group-count">{{ group.items|length }} 项</span>
</div>
<div class="module-group-body">
{% for it in group.items %}
<div class="diff-row">
<div class="diff-item-name tag-recovered">{{ it.name }}</div>
<div class="diff-value">{{ it.value_a }}</div>
<div class="diff-value tag-recovered">{{ it.value_b }}</div>
<div class="diff-change tag-recovered">{{ it.change_desc }}</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- 值变化 -->
{% if data.diff_details.value_changed %}
<div class="diff-section">
<div class="diff-section-title">📊 值变化 <span class="badge badge-changed">{{ data.diff_summary.value_changed }}</span></div>
{% for group in data.diff_details.value_changed %}
<div class="module-group">
<div class="module-group-header" onclick="this.parentElement.classList.toggle('open')">
<span class="module-group-toggle"></span>
<span class="module-group-name">{{ group.module_name }}</span>
<span class="module-group-count">{{ group.items|length }} 项</span>
</div>
<div class="module-group-body">
{% for it in group.items %}
<div class="diff-row">
<div class="diff-item-name tag-changed">{{ it.name }}</div>
<div class="diff-value">{{ it.value_a }}</div>
<div class="diff-value">{{ it.value_b }}</div>
<div class="diff-change tag-changed">{{ it.change_desc }}</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- 新增检测项 -->
{% if data.diff_details.new_items %}
<div class="diff-section">
<div class="diff-section-title">➕ 新增检测项 <span class="badge badge-new">{{ data.diff_summary.new_items }}</span></div>
{% for group in data.diff_details.new_items %}
<div class="module-group">
<div class="module-group-header" onclick="this.parentElement.classList.toggle('open')">
<span class="module-group-toggle"></span>
<span class="module-group-name">{{ group.module_name }}</span>
<span class="module-group-count">{{ group.items|length }} 项</span>
</div>
<div class="module-group-body">
{% for it in group.items %}
<div class="diff-row">
<div class="diff-item-name tag-new">{{ it.name }}</div>
<div class="diff-value">-</div>
<div class="diff-value">{{ it.value_b }}</div>
<div class="diff-change tag-new">新增检测项</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- 消失检测项 -->
{% if data.diff_details.removed_items %}
<div class="diff-section">
<div class="diff-section-title">➖ 消失检测项 <span class="badge badge-removed">{{ data.diff_summary.removed_items }}</span></div>
{% for group in data.diff_details.removed_items %}
<div class="module-group">
<div class="module-group-header" onclick="this.parentElement.classList.toggle('open')">
<span class="module-group-toggle"></span>
<span class="module-group-name">{{ group.module_name }}</span>
<span class="module-group-count">{{ group.items|length }} 项</span>
</div>
<div class="module-group-body">
{% for it in group.items %}
<div class="diff-row">
<div class="diff-item-name tag-removed">{{ it.name }}</div>
<div class="diff-value">{{ it.value_a }}</div>
<div class="diff-value">-</div>
<div class="diff-change tag-removed">消失检测项</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}{# total_diffs check #}
{% endif %}{# data check #}
</div>
{% endblock %}
{% block extra_js %}
<script>
function toggleExport() {
const menu = document.getElementById('export-menu');
menu.classList.toggle('show');
}
document.addEventListener('click', function(e) {
const menu = document.getElementById('export-menu');
if (menu && !e.target.closest('.export-wrap')) {
menu.classList.remove('show');
}
});
</script>
{% endblock %}
......@@ -226,6 +226,48 @@
</div>
</div>
<!-- 连续异常告警 -->
<div class="config-section">
<div class="section-header">
<div class="section-title">🔄 连续异常告警</div>
<label class="toggle-switch">
<input type="checkbox" id="alert-consecutive-enabled" onchange="toggleAlertConsecutive()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="section-body" id="alert-consecutive-section" style="display:none;">
<div class="form-row">
<div class="form-group">
<label>连续次数阈值</label>
<input type="number" id="alert-consecutive-threshold" value="3" min="2" max="10">
<div class="form-hint">同一检测项连续 N 次异常时发送告警</div>
</div>
<div class="form-group">
<label>告警级别</label>
<select id="alert-consecutive-level">
<option value="critical">仅严重</option>
<option value="all">警告 + 严重</option>
</select>
</div>
</div>
<div class="form-group">
<label>通知渠道</label>
<div style="display: flex; gap: 16px; margin-top: 6px;">
<label style="display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: normal; cursor: pointer;">
<input type="checkbox" id="alert-channel-email"> 邮件
</label>
<label style="display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: normal; cursor: pointer;">
<input type="checkbox" id="alert-channel-dingtalk"> 钉钉
</label>
<label style="display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: normal; cursor: pointer;">
<input type="checkbox" id="alert-channel-wecom"> 企微
</label>
</div>
<div class="form-hint">复用上方已配置的通知渠道</div>
</div>
</div>
</div>
<div class="actions">
<button class="btn-save" onclick="saveConfig()">保存配置</button>
</div>
......@@ -271,6 +313,17 @@ async function loadConfig() {
document.getElementById('trigger-abnormal').checked = cfg.trigger?.on_abnormal_only || false;
document.getElementById('trigger-details').checked = cfg.trigger?.include_details !== false;
document.getElementById('trigger-link').checked = cfg.trigger?.include_link !== false;
// 连续异常告警
const alertCfg = cfg.alert_on_consecutive || {};
document.getElementById('alert-consecutive-enabled').checked = alertCfg.enabled || false;
document.getElementById('alert-consecutive-threshold').value = alertCfg.consecutive_threshold || 3;
document.getElementById('alert-consecutive-level').value = alertCfg.alert_level || 'critical';
const channels = alertCfg.channels || [];
document.getElementById('alert-channel-email').checked = channels.includes('email');
document.getElementById('alert-channel-dingtalk').checked = channels.includes('dingtalk');
document.getElementById('alert-channel-wecom').checked = channels.includes('wecom');
toggleAlertConsecutive();
}
} catch (e) {
console.error('加载配置失败:', e);
......@@ -283,6 +336,11 @@ function toggleSection(name) {
section.style.display = enabled ? '' : 'none';
}
function toggleAlertConsecutive() {
const enabled = document.getElementById('alert-consecutive-enabled').checked;
document.getElementById('alert-consecutive-section').style.display = enabled ? '' : 'none';
}
async function testEmail() {
const btn = event.target;
btn.disabled = true;
......@@ -378,6 +436,16 @@ async function saveConfig(silent = false) {
on_abnormal_only: document.getElementById('trigger-abnormal').checked,
include_details: document.getElementById('trigger-details').checked,
include_link: document.getElementById('trigger-link').checked
},
alert_on_consecutive: {
enabled: document.getElementById('alert-consecutive-enabled').checked,
consecutive_threshold: parseInt(document.getElementById('alert-consecutive-threshold').value) || 3,
alert_level: document.getElementById('alert-consecutive-level').value,
channels: [
...(document.getElementById('alert-channel-email').checked ? ['email'] : []),
...(document.getElementById('alert-channel-dingtalk').checked ? ['dingtalk'] : []),
...(document.getElementById('alert-channel-wecom').checked ? ['wecom'] : [])
]
}
};
......
......@@ -168,6 +168,9 @@
</div>
</div>
<div class="export-wrap">
{% if not token_auth %}
<button class="btn-compare" onclick="openCompareModal()" style="background: #10b981; color: #fff; border: none; padding: 9px 18px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; margin-right: 8px;">📊 对比历史报告</button>
{% endif %}
<button class="btn-export" onclick="toggleExport()">⬇ 导出 ▾</button>
<div class="export-menu" id="export-menu">
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=md{% if token_auth %}&token={{ access_token }}{% endif %}">导出 Markdown</a>
......@@ -281,6 +284,45 @@
</div>
{% endfor %}
</div>
<!-- 对比报告选择弹窗 -->
{% if not token_auth %}
<div id="compare-modal" class="modal" style="display:none;">
<div class="modal-overlay" onclick="closeCompareModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3>选择对比报告</h3>
<button class="modal-close" onclick="closeCompareModal()">×</button>
</div>
<div class="modal-body">
<div id="compare-loading" style="text-align:center;padding:20px;color:var(--gray-500);">加载中...</div>
<div id="compare-list" style="display:none;"></div>
</div>
</div>
</div>
<style>
.modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 1000; }
.modal-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); }
.modal-content { position: relative; max-width: 500px; margin: 80px auto; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.2); max-height: 70vh; display: flex; flex-direction: column; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--gray-200); }
.modal-header h3 { margin: 0; font-size: 18px; color: var(--gray-900); }
.modal-close { background: none; border: none; font-size: 24px; color: var(--gray-500); cursor: pointer; padding: 0; line-height: 1; }
.modal-close:hover { color: var(--gray-900); }
.modal-body { padding: 16px 20px; overflow-y: auto; }
.compare-item { display: flex; align-items: center; padding: 12px 16px; border: 1px solid var(--gray-200); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: all .15s; }
.compare-item:hover { border-color: var(--primary); background: var(--gray-50); }
.compare-item.selected { border-color: var(--primary); background: #eff6ff; }
.compare-item input { margin-right: 12px; }
.compare-item .info { flex: 1; }
.compare-item .info .date { font-size: 14px; font-weight: 600; color: var(--gray-900); }
.compare-item .info .summary { font-size: 12px; color: var(--gray-500); margin-top: 2px; }
.modal-footer { padding: 12px 20px; border-top: 1px solid var(--gray-200); display: flex; justify-content: flex-end; gap: 8px; }
@media screen and (max-width: 768px) {
.modal-content { margin: 20px; max-height: 80vh; }
.btn-compare { display: none; }
}
</style>
{% endif %}
{% endblock %}
{% block extra_js %}
......@@ -306,5 +348,71 @@
const d = await r.json();
alert(d.message || '修复能力开发中');
}
// 对比报告功能
let compareReports = [];
let selectedCompareId = null;
async function openCompareModal() {
document.getElementById('compare-modal').style.display = 'block';
document.getElementById('compare-loading').style.display = 'block';
document.getElementById('compare-list').style.display = 'none';
selectedCompareId = null;
try {
const targetId = '{{ report.target_id }}';
const r = await fetch(`/api/service-monitor/reports?target_id=${targetId}&limit=20`, {credentials:'include'});
const d = await r.json();
if (d.success) {
compareReports = d.reports.filter(rp => rp.id !== '{{ report.id }}');
renderCompareList();
}
} catch (e) {
document.getElementById('compare-loading').textContent = '加载失败';
}
}
function renderCompareList() {
const list = document.getElementById('compare-list');
const loading = document.getElementById('compare-loading');
if (compareReports.length === 0) {
list.innerHTML = '<div style="text-align:center;padding:20px;color:var(--gray-500);">暂无其他历史报告</div>';
} else {
list.innerHTML = compareReports.map(rp => {
const s = rp.summary || {};
const date = (rp.finished_at || rp.started_at || '').replace('T', ' ');
const summary = `正常 ${s['正常']||0} / 警告 ${s['警告']||0} / 严重 ${s['严重']||0}`;
return `<div class="compare-item" onclick="selectCompare('${rp.id}', this)">
<input type="radio" name="compare-select" value="${rp.id}" ${selectedCompareId===rp.id?'checked':''}>
<div class="info">
<div class="date">${date}</div>
<div class="summary">${summary}</div>
</div>
</div>`;
}).join('') + `<div class="modal-footer"><button class="btn-action btn-primary" onclick="doCompare()" ${!selectedCompareId?'disabled style="opacity:0.5"':''}>开始对比</button></div>`;
}
loading.style.display = 'none';
list.style.display = 'block';
}
function selectCompare(id, el) {
selectedCompareId = id;
document.querySelectorAll('.compare-item').forEach(item => item.classList.remove('selected'));
el.classList.add('selected');
el.querySelector('input').checked = true;
const btn = document.querySelector('.modal-footer .btn-primary');
if (btn) { btn.disabled = false; btn.style.opacity = '1'; }
}
function doCompare() {
if (!selectedCompareId) return;
window.location.href = `/service-monitor/compare/{{ report.id }}/${selectedCompareId}`;
}
function closeCompareModal() {
document.getElementById('compare-modal').style.display = 'none';
}
</script>
{% endblock %}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论