提交 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)的全部历史与当前状态 > 专属交接文档:聚焦服务监测模块(service_monitor)的全部历史与当前状态
--- ---
...@@ -17,15 +17,22 @@ ...@@ -17,15 +17,22 @@
- **阶段五**:移植剩余 P3 级检测模块:FastDFS功能验证 + Android设备检测 + 数据备份检测 + 配置日志导出检测 + 修复能力检测 - **阶段五**:移植剩余 P3 级检测模块:FastDFS功能验证 + Android设备检测 + 数据备份检测 + 配置日志导出检测 + 修复能力检测
- **阶段六**:巡检防重复触发优化 + 数据备份检测超时修复 - **阶段六**:巡检防重复触发优化 + 数据备份检测超时修复
- **阶段七**:模块清理 + 5.44 验证 + 移动端响应式样式优化 - **阶段七**:模块清理 + 5.44 验证 + 移动端响应式样式优化
- **阶段八(本次)**:移动端响应式深度优化 — 新增 480px 超小屏幕断点 + 修正布局溢出问题 - **阶段八**:移动端响应式深度优化 — 新增 480px 超小屏幕断点 + 修正布局溢出问题
- **阶段九(本次)**:功能增强 — 报告对比功能 + 异常项趋势告警
本轮会话完成了以下工作: 本轮会话完成了以下工作:
1. **新增 480px 断点**:9 个页面全部新增 `@media (max-width: 480px)` 超小屏幕适配,覆盖 iPhone SE 等小屏设备 **P1:报告对比功能**
2. **修正 statistics.html 筛选栏溢出**:768px 断点下筛选栏从 `flex-wrap` 改为完全纵向堆叠 1. 新增 `compare_service.py` 对比服务,支持 5 类差异分类(新增异常/恢复正常/值变化/新增项/消失项)
3. **修正 notification.html 端口输入框溢出**`flex: 0 0 120px` 固定宽度改为自适应 2. 新增 API:`GET /api/service-monitor/reports/{id1}/compare/{id2}` 和导出接口
4. **修正 report.html 模块标题溢出**:添加 `word-break: break-word` 3. 新增 `compare.html` 对比结果页,差异按模块分组展示
5. **run.html 进度条响应式高度**:12→10→8px 三级递减 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 后) > **检测项丰富化移植全部完成**:阶段一至五共新增 15 个检测模块,full 套件模块数 30 → 42(移除 58 后)
> >
...@@ -618,7 +625,7 @@ ...@@ -618,7 +625,7 @@
## 3. 当前卡在哪 ## 3. 当前卡在哪
**无卡点**。阶段一至全部完成。 **无卡点**。阶段一至全部完成。
**当前状态** **当前状态**
- 服务健康检查:status: ok, scheduler: running ✅ - 服务健康检查:status: ok, scheduler: running ✅
...@@ -649,6 +656,8 @@ ...@@ -649,6 +656,8 @@
- 5.44 目标凭据已配置(MySQL/Redis 密码加密注入)✅ - 5.44 目标凭据已配置(MySQL/Redis 密码加密注入)✅
- **移动端响应式优化完成**(9 个页面全部适配)✅ - **移动端响应式优化完成**(9 个页面全部适配)✅
- **移动端响应式深度优化完成**(新增 480px 断点 + 修正溢出问题)✅ - **移动端响应式深度优化完成**(新增 480px 断点 + 修正溢出问题)✅
- **报告对比功能完成**(对比服务 + API + 页面 + 导出)✅
- **异常项趋势告警完成**(连续异常检测 + 告警通知 + 配置页)✅
--- ---
...@@ -666,49 +675,37 @@ ...@@ -666,49 +675,37 @@
| ~~P2~~ | ~~在 5.44 实际运行验证新模块~~ | ✅ 已完成(10 个新模块全部正常) | | ~~P2~~ | ~~在 5.44 实际运行验证新模块~~ | ✅ 已完成(10 个新模块全部正常) |
| ~~P1~~ | ~~移动端响应式优化~~ | ✅ 已完成(9 个页面全部适配) | | ~~P1~~ | ~~移动端响应式优化~~ | ✅ 已完成(9 个页面全部适配) |
| ~~P1~~ | ~~移动端响应式深度优化~~ | ✅ 已完成(新增 480px 断点 + 修正溢出) | | ~~P1~~ | ~~移动端响应式深度优化~~ | ✅ 已完成(新增 480px 断点 + 修正溢出) |
| ~~P1~~ | ~~报告对比功能~~ | ✅ 已完成(阶段九) |
| ~~P1~~ | ~~异常项趋势告警~~ | ✅ 已完成(阶段九) |
| P2 | 导出 PDF/Excel 格式 | 待开发 |
| 持续 | 更新 HANDOFF_服务监测文档 | 每次会话结束前更新进度 | | 持续 | 更新 HANDOFF_服务监测文档 | 每次会话结束前更新进度 |
--- ---
## 4.1 下一阶段规划:功能增强(阶段九) ## 4.1 阶段九进度:功能增强
> 以下为下一轮会话的计划任务,按优先级排序
### P1:报告对比功能
**需求**:对比两次巡检结果,快速定位变化项
**功能点**
- 报告详情页新增"对比历史报告"按钮
- 选择另一份报告进行对比
- 差异展示:
- 新增异常项(原正常→现异常)
- 恢复正常项(原异常→现正常)
- 值变化项(数值对比,如 CPU 使用率 45%→78%)
- 新增/消失检测项(模块变更导致)
- 导出对比报告(Markdown/HTML)
**技术方案**
- 新增 API:`GET /api/service-monitor/reports/{id1}/compare/{id2}`
- 对比逻辑:按检测项 KEY 匹配,比对 value 和 status
- 前端:复用 report.html 布局,差异项高亮显示
### 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)
**功能点** ### ✅ P1:异常项趋势告警(已完成)
- 通知配置页新增"连续异常告警"设置
- 连续次数阈值(默认 3 次)
- 告警级别(仅严重 / 警告+严重)
- 通知渠道(复用现有邮件/钉钉/企微)
- 检测逻辑:每次报告完成后,统计最近 N 次报告的异常项
- 告警消息模板:`⚠️ 【目标名称】检测项"XXX"连续 3 次异常,请关注`
**技术方案** **实现内容**
- 新增字段:`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 格式 ### P2:导出 PDF/Excel 格式
...@@ -834,6 +831,7 @@ ...@@ -834,6 +831,7 @@
| 日期 | Commit | 说明 | | 日期 | Commit | 说明 |
|------|--------|------| |------|--------|------|
| 2026-07-23 | 待提交 | feat(service-monitor): 报告对比功能 + 异常项趋势告警 |
| 2026-07-23 | `8e7a5b3a` | style(service-monitor): 移动端响应式深度优化-新增480px超小屏幕断点 | | 2026-07-23 | `8e7a5b3a` | style(service-monitor): 移动端响应式深度优化-新增480px超小屏幕断点 |
| 2026-07-23 | `dfe01cae` | feat(service-monitor): P3级检测模块补全+巡检防重复触发+移动端响应式优化 | | 2026-07-23 | `dfe01cae` | feat(service-monitor): P3级检测模块补全+巡检防重复触发+移动端响应式优化 |
| 2026-07-22 | `664d318d` | feat(service-monitor): P3级移植-文件权限检测/定时任务预期检测 | | 2026-07-22 | `664d318d` | feat(service-monitor): P3级移植-文件权限检测/定时任务预期检测 |
...@@ -937,11 +935,28 @@ feat(service-monitor): 新增安全合规/中间件检测模块并修复状态 ...@@ -937,11 +935,28 @@ feat(service-monitor): 新增安全合规/中间件检测模块并修复状态
| `templates/service_monitor/notification.html` | 修正 768px 端口输入框自适应 + 新增 480px 断点 | | `templates/service_monitor/notification.html` | 修正 768px 端口输入框自适应 + 新增 480px 断点 |
| `templates/service_monitor/run.html` | 新增进度条响应式高度(12→10→8px)+ 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` | 报告对比功能计划执行文档 |
| `Docs/需求文档/服务监测/PRD_计划执行_异常项趋势告警.md` | 异常项趋势告警计划执行文档 |
### 参考目录 ### 参考目录
......
...@@ -22,7 +22,7 @@ from flask import ( ...@@ -22,7 +22,7 @@ from flask import (
Response, redirect, url_for, stream_with_context, 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") logger = logging.getLogger("service_monitor.routes")
...@@ -450,6 +450,83 @@ def api_export_report(report_id): ...@@ -450,6 +450,83 @@ def api_export_report(report_id):
return resp 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 ...@@ -5,3 +5,4 @@ from . import report_service
from . import runner_service from . import runner_service
from . import schedule_service from . import schedule_service
from . import statistics_service from . import statistics_service
from . import compare_service
...@@ -264,3 +264,158 @@ def validate_access_token(report_id: str, token: str) -> bool: ...@@ -264,3 +264,158 @@ def validate_access_token(report_id: str, token: str) -> bool:
return False return False
return True 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 — 巡检执行编排 ...@@ -13,10 +13,13 @@ runner_service.py — 巡检执行编排
from __future__ import annotations from __future__ import annotations
import hashlib
import hmac
import logging import logging
import threading import threading
import time import time
import traceback import traceback
import urllib.parse
from datetime import datetime from datetime import datetime
from typing import Dict, Optional from typing import Dict, Optional
...@@ -302,6 +305,14 @@ def run_inspection_sync(target_id: str, suite: str) -> dict: ...@@ -302,6 +305,14 @@ def run_inspection_sync(target_id: str, suite: str) -> dict:
if report.get("access_token"): if report.get("access_token"):
report_url = f"{report_url}?token={report['access_token']}" report_url = f"{report_url}?token={report['access_token']}"
notification_service.send_notification(report, report_url) 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: except Exception as e:
logger.warning("发送通知失败: %s", e) logger.warning("发送通知失败: %s", e)
...@@ -322,3 +333,174 @@ def run_inspection_sync(target_id: str, suite: str) -> dict: ...@@ -322,3 +333,174 @@ def run_inspection_sync(target_id: str, suite: str) -> dict:
executor.cleanup() executor.cleanup()
except Exception as e: except Exception as e:
logger.warning("执行器清理失败: %s", 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)
此差异已折叠。
...@@ -226,6 +226,48 @@ ...@@ -226,6 +226,48 @@
</div> </div>
</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"> <div class="actions">
<button class="btn-save" onclick="saveConfig()">保存配置</button> <button class="btn-save" onclick="saveConfig()">保存配置</button>
</div> </div>
...@@ -271,6 +313,17 @@ async function loadConfig() { ...@@ -271,6 +313,17 @@ async function loadConfig() {
document.getElementById('trigger-abnormal').checked = cfg.trigger?.on_abnormal_only || false; document.getElementById('trigger-abnormal').checked = cfg.trigger?.on_abnormal_only || false;
document.getElementById('trigger-details').checked = cfg.trigger?.include_details !== false; document.getElementById('trigger-details').checked = cfg.trigger?.include_details !== false;
document.getElementById('trigger-link').checked = cfg.trigger?.include_link !== 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) { } catch (e) {
console.error('加载配置失败:', e); console.error('加载配置失败:', e);
...@@ -283,6 +336,11 @@ function toggleSection(name) { ...@@ -283,6 +336,11 @@ function toggleSection(name) {
section.style.display = enabled ? '' : 'none'; 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() { async function testEmail() {
const btn = event.target; const btn = event.target;
btn.disabled = true; btn.disabled = true;
...@@ -378,6 +436,16 @@ async function saveConfig(silent = false) { ...@@ -378,6 +436,16 @@ async function saveConfig(silent = false) {
on_abnormal_only: document.getElementById('trigger-abnormal').checked, on_abnormal_only: document.getElementById('trigger-abnormal').checked,
include_details: document.getElementById('trigger-details').checked, include_details: document.getElementById('trigger-details').checked,
include_link: document.getElementById('trigger-link').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 @@ ...@@ -168,6 +168,9 @@
</div> </div>
</div> </div>
<div class="export-wrap"> <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> <button class="btn-export" onclick="toggleExport()">⬇ 导出 ▾</button>
<div class="export-menu" id="export-menu"> <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> <a href="/api/service-monitor/reports/{{ report.id }}/export?format=md{% if token_auth %}&token={{ access_token }}{% endif %}">导出 Markdown</a>
...@@ -281,6 +284,45 @@ ...@@ -281,6 +284,45 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </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 %} {% endblock %}
{% block extra_js %} {% block extra_js %}
...@@ -306,5 +348,71 @@ ...@@ -306,5 +348,71 @@
const d = await r.json(); const d = await r.json();
alert(d.message || '修复能力开发中'); 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> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论