提交 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
......@@ -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)
此差异已折叠。
......@@ -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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论