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

feat(service-monitor): 报告免登访问 + 监测统计模块 + 多项修复

- 报告免登访问:通知链接携带 token,免登录直接查看报告
- 操作鉴权:访客模式下点击其他模块跳转登录
- 监测统计模块:新增统计页面,ECharts 可视化图表
- 检测项趋势修复:状态类检测项显示友好提示
- 登录回跳:next 参数支持登录后回到原始页面
- 新增 8 个 PRD 文档:需求/计划执行/问题处理
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 dc45e871
# 计划执行:检测项趋势图表显示问题修复
## 文档信息
- 创建时间:2026-07-22
- 关联问题文档:`Docs/需求文档/服务监测/PRD_问题处理_检测项趋势图表显示问题.md`
- 预计改动文件:1 个
- 预计新增文件:0 个
---
## 执行步骤
### Step 1:前端 — 折线图无数值数据时显示友好提示
**文件**`skill/code/web/templates/service_monitor/statistics.html`
**位置**`fetchItemTrend` 函数,数据加载后
**改动**:在渲染折线图之前,检查数据点中是否有数值数据。如果全部无数值,显示提示信息代替空白图表。
**具体逻辑**
```javascript
// 在 fetchItemTrend 函数中,获取 data_points 后:
const numericCount = data.data_points.filter(p => p.value_num !== null).length;
const totalCount = data.data_points.length;
// 情况1:无数据点
if (totalCount === 0) {
chartTrendLine.setOption({
title: { text: '该检测项暂无数据', left: 'center', top: 'center',
textStyle: { color: '#9ca3af', fontSize: 14 } }
}, true);
chartTrendPie.setOption({
title: { text: '暂无数据', left: 'center', top: 'center',
textStyle: { color: '#9ca3af', fontSize: 14 } }
}, true);
return;
}
// 情况2:有数据点但无数值(状态类/文本类)
if (numericCount === 0) {
chartTrendLine.setOption({
title: {
text: '该检测项为状态/文本类型',
subtext: '无法绘制数值趋势图,请查看右侧状态分布',
left: 'center', top: 'center',
textStyle: { color: '#6b7280', fontSize: 14 },
subtextStyle: { color: '#9ca3af', fontSize: 12 }
}
}, true);
// 饼图正常渲染(只有状态分布)
// ... existing pie chart code ...
return;
}
// 情况3:有数值数据,正常渲染折线图
// ... existing line chart code ...
```
---
### Step 2:前端 — 模块选择器中标注无趋势项
**文件**`skill/code/web/templates/service_monitor/statistics.html`
**位置**`onModuleChange` 函数
**改动**:在检测项选择器中,对无趋势分析能力的项添加标记(如 "(状态)" 后缀),让用户一目了然。
**实现方式**:在 `get_item_keys_for_target` API 返回中增加 `has_numeric` 标记,前端根据标记添加后缀。
---
### Step 3:后端 — item-keys API 增加 has_numeric 标记
**文件**`skill/code/web/service_monitor/services/statistics_service.py`
**位置**`get_item_keys_for_target` 函数
**改动**:在收集检测项时,同时检查该项是否有过数值数据,增加 `has_numeric` 字段。
```python
# 在 items_map 中增加 has_numeric 字段
items_map[key] = {
"key": key,
"name": item.get("name", key),
"has_numeric": False, # 默认 False
}
# 遍历报告时,检查该项是否有数值数据
for item in module.get("items", []):
key = item.get("key", "")
value_raw = item.get("value", "")
value_num, _ = _extract_numeric(value_raw)
if value_num is not None:
items_map[key]["has_numeric"] = True
```
---
### Step 4:前端 — 检测项选择器标注
**文件**`skill/code/web/templates/service_monitor/statistics.html`
**位置**`onModuleChange` 函数
**改动**:根据 `has_numeric` 字段在选项中添加后缀标记。
```javascript
group.items.forEach(item => {
const opt = document.createElement('option');
opt.value = item.key;
opt.textContent = item.name + (item.has_numeric ? '' : ' (状态)');
itemSel.appendChild(opt);
});
```
---
### Step 5:本地测试
```bash
cd skill/code && python -m pytest -v
```
---
### Step 6:部署验证
```bash
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
```
**验证**
1. 选择 `CPU_USAGE` 等数值项,折线图正常
2. 选择 `CPU_STATUS` 等状态项,显示友好提示 + 饼图正常
3. 检测项选择器中,状态项标注 "(状态)"
4. 控制台无 JS 错误
---
## 验收清单
| 检查项 | 状态 |
|--------|------|
| 折线图无数值时显示友好提示 | ⬜ |
| 状态类检测项饼图正常显示 | ⬜ |
| item-keys API 返回 has_numeric 字段 | ⬜ |
| 检测项选择器标注 "(状态)" 后缀 | ⬜ |
| 本地测试通过 | ⬜ |
| 部署验证通过 | ⬜ |
---
## 执行记录
| 步骤 | 状态 | 时间 | 备注 |
|------|------|------|------|
| Step 1: 折线图友好提示 | 待执行 | - | - |
| Step 2: 后端 has_numeric 标记 | 待执行 | - | - |
| Step 3: 前端选择器标注 | 待执行 | - | - |
| Step 4: 本地测试 | 待执行 | - | - |
| Step 5: 部署验证 | 待执行 | - | - |
# 计划执行:监测统计模块
## 文档信息
- 创建时间:2026-07-22
- 关联需求文档:`Docs/需求文档/服务监测/PRD_需求文档_监测统计模块.md`
- 预计改动文件:4 个
- 预计新增文件:2 个
---
## 执行步骤
### Step 1:后端 — statistics_service 统计聚合服务
**文件**`skill/code/web/service_monitor/services/statistics_service.py`(新增)
**核心函数**
```python
# 常量
NORMAL, WARNING, CRITICAL = "正常", "警告", "严重"
DEFAULT_RANGE_DAYS = 30
MAX_REPORTS_LOAD = 200
# 内部辅助
def _parse_date_filter(target_id, start_date, end_date) -> (target_id, start_iso, end_iso)
def _load_reports_in_range(target_id, start_date, end_date, limit=200) -> List[dict]
# 公开 API
def get_overview(target_id, start_date, end_date) -> dict
def get_module_stats(target_id, start_date, end_date) -> dict
def get_item_trend(target_id, item_key, start_date, end_date) -> dict
def get_abnormal_items(target_id, start_date, end_date, top_n=10) -> dict
def get_item_keys_for_target(target_id, start_date, end_date) -> List[dict]
```
**`_load_reports_in_range` 实现策略**
1. 调用 `report_service.list_reports(target_id=target_id, limit=MAX_REPORTS_LOAD)` 获取摘要列表
2.`started_at` 过滤日期范围
3. 对匹配的摘要调用 `report_service.get_report(id)` 获取完整数据
4.`started_at` 升序排序(时间序列需要)
**`get_overview` 实现**:遍历报告,累积 total/normal/warning/critical 计数,计算异常率,找出最常异常项
**`get_module_stats` 实现**:遍历报告的 modules,按 module_id 累积计数,计算 pass_rate
**`get_item_trend` 实现**:扫描每份报告的所有 items,匹配 item_key,用 `_extract_numeric()` 提取数值,构建时间序列
**`get_abnormal_items` 实现**:按 item_key 统计异常次数,降序取 TOP 10
**`get_item_keys_for_target` 实现**:扫描报告,收集所有出现的 item_key,去重后返回
**依赖**
- `report_service.list_reports()` / `report_service.get_report()`
- `thresholds._extract_numeric()` 数值提取
- `display_names.DISPLAY_NAMES` 键名映射
---
### Step 2:后端 — services/__init__.py 导入
**文件**`skill/code/web/service_monitor/services/__init__.py`
**改动**:添加 `from . import statistics_service`
---
### Step 3:后端 — routes 添加页面路由 + API
**文件**`skill/code/web/service_monitor/routes.py`
**3.1 导入**:添加 `statistics_service` 到 import 行
**3.2 页面路由**
```python
@bp.route('/service-monitor/statistics')
def page_statistics():
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
targets = target_service.list_targets(role=user.get('role', ''))
return render_template('service_monitor/statistics.html',
user=user, targets=targets,
is_admin=(user.get('role') == 'admin'),
active_menu='statistics')
```
**3.3 API 端点**(5 个):
- `GET /api/service-monitor/statistics/overview`
- `GET /api/service-monitor/statistics/modules`
- `GET /api/service-monitor/statistics/item-trend`
- `GET /api/service-monitor/statistics/abnormal-items`
- `GET /api/service-monitor/statistics/item-keys`
---
### Step 4:前端 — base.html 侧边栏菜单
**文件**`skill/code/web/templates/service_monitor/base.html`
**位置**:在"巡检报告"菜单项之后添加
```html
<a href="/service-monitor/statistics" class="nav-item {{ 'active' if active_menu == 'statistics' }}">
<span class="nav-icon">📈</span>
<span>监测统计</span>
</a>
```
---
### Step 5:前端 — statistics.html 统计页面
**文件**`skill/code/web/templates/service_monitor/statistics.html`(新增)
**页面结构**
1. **筛选栏**:目标选择器 + 日期范围 + 查询按钮
2. **概览卡片**:4 卡片网格(总报告数、异常率、最常异常项、平均检测项)
3. **模块健康概览**:ECharts 堆叠条形图
4. **检测项趋势分析**:检测项选择器 + 折线图(2/3)+ 饼图(1/3)
5. **高频异常检测项**:ECharts 水平堆叠条形图 TOP 10
**ECharts CDN**`https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js`
**颜色方案**
- 正常:`#16a34a`
- 警告:`#d97706`
- 严重:`#dc2626`
**JavaScript 逻辑**
- 页面加载时初始化 ECharts 实例,设置默认日期范围,首次加载数据
- 查询按钮触发 `fetchAllData()` 并行请求 overview + modules + abnormal-items
- 检测项选择器变化时触发 `fetchItemTrend()` 仅刷新趋势区域
- 高频异常项点击时自动填充检测项选择器并刷新趋势
- 窗口 resize 时调用 `chart.resize()`
- 无数据时显示空状态提示
---
### Step 6:本地测试
**命令**
```bash
cd skill/code && python -m pytest -v
```
**重点验证**
- statistics_service 各函数返回正确数据结构
- 日期过滤逻辑正确
- 数值提取对百分比/纯数字/带单位值正确
- 空数据场景不报错
- 已有测试不受影响
---
### Step 7:部署验证
**命令**
```bash
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
```
**验证步骤**
1. 登录后点击左侧"监测统计"菜单
2. 确认页面正常加载,筛选栏和图表区域显示
3. 选择目标服务器,设置日期范围,点击查询
4. 确认概览卡片数据正确
5. 确认模块健康图表正常渲染
6. 选择检测项,确认趋势图和饼图显示
7. 确认高频异常项图表正确
---
## 回滚方案
1. 还原各文件:
```bash
git checkout HEAD~1 -- skill/code/web/service_monitor/routes.py
git checkout HEAD~1 -- skill/code/web/service_monitor/services/__init__.py
git checkout HEAD~1 -- skill/code/web/templates/service_monitor/base.html
```
2. 删除新增文件:
```bash
rm skill/code/web/service_monitor/services/statistics_service.py
rm skill/code/web/templates/service_monitor/statistics.html
```
3. 重新部署
---
## 验收清单
| 检查项 | 状态 |
|--------|------|
| statistics_service.py 5 个公开函数 | ⬜ |
| services/__init__.py 导入 | ⬜ |
| routes 页面路由 + 5 个 API | ⬜ |
| base.html 侧边栏菜单项 | ⬜ |
| statistics.html 完整页面 | ⬜ |
| ECharts 图表正常渲染 | ⬜ |
| 筛选功能正常 | ⬜ |
| 检测项趋势分析正常 | ⬜ |
| 本地测试通过 | ⬜ |
| 部署验证通过 | ⬜ |
---
## 执行记录
| 步骤 | 状态 | 时间 | 备注 |
|------|------|------|------|
| Step 1: statistics_service | 待执行 | - | - |
| Step 2: __init__.py 导入 | 待执行 | - | - |
| Step 3: routes 路由 | 待执行 | - | - |
| Step 4: base.html 菜单 | 待执行 | - | - |
| Step 5: statistics.html 页面 | 待执行 | - | - |
| Step 6: 本地测试 | 待执行 | - | - |
| Step 7: 部署验证 | 待执行 | - | - |
# 问题处理:检测项趋势图表部分项无法正常显示
## 文档信息
- 创建时间:2026-07-22
- 问题来源:用户反馈
- 关联模块:service_monitor/statistics
---
## 1. 问题描述
### 现象
检测项趋势分析功能中,选择部分检测项后,折线图无法正常显示:
- 折线图区域空白
- 仅右侧饼图显示状态分布
- 无错误提示,但用户认为"图表没出来"
### 影响范围
经全量测试 419 个检测项:
| 类型 | 数量 | 状态 |
|------|------|------|
| 有数值数据(可画折线图) | 264 | ✅ 正常 |
| 无数值数据(折线图空白) | 140 | ❌ 问题项 |
| 状态类(值="正常"/"异常") | 70 | 属设计限制 |
| 表格/多行类 | 3 | 数据格式特殊 |
| 其他文本类 | 67 | 值为 yes/no/路径等 |
---
## 2. 根因分析
### 2.1 数据来源
检测项的值(`value_raw`)是字符串,来自巡检脚本的输出。`statistics_service.py` 使用 `_extract_numeric()` 函数尝试提取数值:
```python
value_num, unit = _extract_numeric(value_raw)
```
### 2.2 `_extract_numeric` 的处理逻辑
该函数能提取以下格式:
- 百分比:`"92%"``(92.0, "%")`
- 纯数字:`"3.69"``(3.69, "")`
- 带单位:`"10s"`, `"500MB"`, `"1.5G"`
**无法提取的格式**
- 状态文本:`"正常"`, `"异常"`, `"运行中"`
- 是/否:`"yes"`, `"no"`
- 多行表格数据
- 路径、命令名等
### 2.3 前端行为
当前前端 `fetchItemTrend` 函数:
- 收到 `data_points` 后,仅使用 `value_num` 绘制折线图
- 如果所有 `value_num` 都是 `null`,折线图为空
- 饼图使用 `status` 字段,正常工作
---
## 3. 问题分类
### 3.1 状态类检测项(70 个)
典型代表:`CPU_STATUS`, `MEMORY_STATUS`, `DOCKER_STATUS`
**值示例**`"正常"`, `"异常"`
**预期行为**:无法画折线图,但饼图可以显示状态分布。这是合理的设计限制。
### 3.2 表格/多行类(3 个)
- `IRQ_DETAIL`:中断详情表格
- `SOFTIRQS`:软中断统计表格
- `DISK_LATENCY`:磁盘延迟表格
**值示例**:多行文本,每行是一个表格行
**预期行为**:这种数据不适合时间序列折线图。
### 3.3 其他文本类(67 个)
典型代表:
- `SSH_PERMIT_ROOT`: `"yes"` / `"no"`
- `NTP_DAEMON`: `"chronyd"`
- `SYSTEM_TIMEZONE`: `"Asia/Shanghai"`
- `CPU_PER_CORE`: `"未安装mpstat"`
- `ABNORMAL_ACCOUNTS`: `"无"`
**问题**:这些项理论上可能有数值(如异常账户数),但当前输出格式是文本。
---
## 4. 解决方案
### 方案 A:前端优化(推荐)
**改动点**:前端 `fetchItemTrend` 函数,检测到无数值数据时,显示友好提示。
```javascript
// 检测是否有数值数据
if (numeric_count === 0 && non_numeric_count > 0) {
// 显示提示信息
chartTrendLine.setOption({
title: {
text: '该检测项为非数值类型,无法绘制趋势折线',
subtext: '请查看右侧状态分布饼图',
left: 'center', top: 'center',
textStyle: { color: '#6b7280', fontSize: 14 }
}
});
return; // 不渲染折线
}
```
**优点**
- 用户明确知道为何无折线图
- 不改后端逻辑,风险低
- 状态类项的饼图仍然可用
### 方案 B:过滤非数值项(可选)
**改动点**`get_item_keys_for_target` 函数,在填充选择器时,标记哪些项有数值数据。
**缺点**:会隐藏大量检测项,用户无法查看其状态分布。
### 方案 C:后端增强数值提取(长期)
**改动点**:扩展 `_extract_numeric` 函数,支持更多格式:
- `"yes"``(1, "")`, `"no"``(0, "")`
- `"正常"``(1, "")`, `"异常"``(0, "")`
- `"无"``(0, "")`
**缺点**:可能引入误判,如把路径 `"yes/bin"` 误解析。
---
## 5. 推荐方案
采用 **方案 A**,在前端添加友好提示。
### 修改文件
`skill/code/web/templates/service_monitor/statistics.html`
### 修改内容
`fetchItemTrend` 函数中,数据加载后检查数值情况:
1. 如果所有 `value_num` 都是 `null`,显示提示信息
2. 提示内容:"该检测项为状态类或文本类,无法绘制数值趋势图"
3. 饼图正常渲染状态分布
---
## 6. 验收标准
| 标准 | 验证方法 |
|------|---------|
| 选择 `CPU_USAGE` 等数值项 | 折线图正常显示 |
| 选择 `CPU_STATUS` 等状态项 | 显示友好提示,饼图正常 |
| 选择 `IRQ_DETAIL` 等表格项 | 显示友好提示 |
| 控制台无 JS 错误 | 检查浏览器控制台 |
---
## 7. 后续建议
1. **数据输出规范化**:巡检脚本输出时,状态类项可额外输出数值版本(如 `"正常"` + `"0"` 异常数)
2. **选择器分组**:检测项选择器可按"可趋势分析"/"状态类"分组
3. **数据格式优化**:表格类数据可解析为结构化格式单独展示
\ No newline at end of file
# 问题处理:统计页面 JavaScript 错误
## 文档信息
- 创建时间:2026-07-22
- 问题来源:用户反馈
- 关联模块:service_monitor/statistics
---
## 1. 问题描述
### 错误信息
```
statistics:349 Uncaught ReferenceError: fetchAllData is not defined
at HTMLButtonElement.onclick (statistics:349:60)
```
### 触发条件
打开监测统计页面后,点击"查询"按钮触发错误。
---
## 2. 根因分析
### 代码残留问题
在编辑 `statistics.html` 时,替换 `fetchItemKeys` 函数后,旧代码片段未被完全删除,导致:
**问题代码(第 330-333 行)**
```javascript
});
if (current) sel.value = current;
} catch (e) { console.error('fetchItemKeys error:', e); }
}
```
这段代码是旧版 `fetchItemKeys` 函数的尾部残留,它:
1. 错误闭合了新 `fetchItemKeys` 函数
2. 导致后续 `fetchItemTrend``fetchAllData` 等函数定义在错误的函数体内
3. 最终 `fetchAllData` 在全局作用域不可见
### 根本原因
编辑时使用 `Edit` 工具替换函数,但 `old_string` 未精确匹配旧代码的完整范围,只替换了函数开头,尾部残留。
---
## 3. 影响范围
| 影响 | 说明 |
|------|------|
| 统计页面完全不可用 | 所有 API 调用失败 |
| 查询按钮点击报错 | `fetchAllData` 未定义 |
| 模块选择器不联动 | `onModuleChange` 可能受影响 |
---
## 4. 修复方案
### 方案:删除残留代码
删除第 330-333 行的残留代码,确保 `fetchItemKeys` 函数正确闭合。
**修复前(第 298-334 行)**
```javascript
}
} catch (e) { console.error('fetchItemKeys error:', e); }
}
// 模块选择变化时,更新检测项选择器
function onModuleChange() {
...
}
}); // ← 残留开始
if (current) sel.value = current;
} catch (e) { console.error('fetchItemKeys error:', e); }
}
// 检测项趋势
```
**修复后**
```javascript
}
} catch (e) { console.error('fetchItemKeys error:', e); }
}
// 模块选择变化时,更新检测项选择器
function onModuleChange() {
...
}
// 检测项趋势
```
---
## 5. 预防措施
1. **编辑前完整阅读原代码**:确保 `old_string` 匹配完整的函数范围
2. **编辑后检查文件完整性**:查看修改后的文件是否有语法错误
3. **本地测试后再部署**:在浏览器控制台验证无 JS 错误
---
## 6. 验收标准
| 标准 | 验证方法 |
|------|---------|
| 页面无 JavaScript 错误 | 打开浏览器控制台检查 |
| 查询按钮正常工作 | 点击查询,数据刷新 |
| 模块选择器联动正常 | 选择模块后检测项填充 |
| 趋势图表正常显示 | 选择检测项后图表渲染 |
\ No newline at end of file
# 需求文档:报告免登访问 + 操作鉴权拦截
## 文档信息
- 创建时间:2026-07-21
- 需求来源:用户反馈
- 关联模块:service_monitor/report + auth
---
## 1. 背景与目标
### 1.1 背景
当前钉钉/企业微信/邮件通知中的报告链接格式为:
```
http://192.168.5.60:8088/service-monitor/report/20260721_143000_ab12cd
```
用户点击链接后存在两个痛点:
1. **必须登录才能看报告**:用户从钉钉消息点击链接 → 跳转到登录页 → 登录后跳转到首页 `/`(不是报告页)→ 用户需要手动找到报告。体验割裂,尤其是手机端操作不便。
2. **登录后无边界控制**:用户通过报告链接进入系统后,如果点击了其他模块(如目标管理、通知配置等),可以自由访问所有功能,没有权限边界。
### 1.2 目标
1. **报告免登访问**:通知中的报告链接携带一次性 token,点击即可直接查看报告,无需登录
2. **操作鉴权拦截**:通过 token 进入报告页的用户,如果点击了其他模块或功能(导航菜单、管理操作等),自动跳转到登录页要求登录
3. **安全性保障**:token 有时效性、一次性使用、不可猜测
---
## 2. 需求详述
### 2.1 报告免登访问
#### 核心流程
```
钉钉通知 → 点击链接(带 token)→ 服务端验证 token → 直接渲染报告页
```
#### Token 设计
| 属性 | 值 | 说明 |
|------|-----|------|
| 格式 | `rpt_<32位随机hex>` | 前缀 `rpt_` 标识报告 token,与 session token 区分 |
| 生成时机 | 报告生成后、发送通知前 | 在 `runner_service.run_inspection_sync()` 中生成 |
| 存储 | 报告 JSON 文件内 `access_token` 字段 | 与报告数据同生命周期 |
| 有效期 | 7 天(可配置) | 超时后 token 失效,需登录查看 |
| 使用次数 | 不限 | 同一 token 可多次访问(钉钉群内多人点击) |
| 失效条件 | 1. 超过有效期 2. 报告被删除 | 报告删除时 token 自然失效 |
#### 链接格式
**改前**
```
http://192.168.5.60:8088/service-monitor/report/20260721_143000_ab12cd
```
**改后**
```
http://192.168.5.60:8088/service-monitor/report/20260721_143000_ab12cd?token=rpt_a1b2c3d4e5f6...
```
#### 服务端处理逻辑
1. 访问 `/service-monitor/report/<id>?token=rpt_xxx`
2. 检查 `token` 参数是否存在
3. 如果存在:
- 从报告 JSON 读取 `access_token``access_token_expires_at`
- 验证 token 匹配 + 未过期
- 验证通过:渲染报告页,标记 `token_auth=True`(只读访客模式)
- 验证失败:跳转登录页
4. 如果不存在:
- 检查 session 登录状态(原有逻辑不变)
- 未登录:跳转登录页
---
### 2.2 操作鉴权拦截
#### 核心概念:只读访客模式
通过 token 进入报告页的用户处于**只读访客模式**,与正常登录用户的关键区别:
| 行为 | 正常登录用户 | 只读访客(token 访问) |
|------|------------|---------------------|
| 查看报告内容 | ✅ | ✅ |
| 导出报告(MD/JSON) | ✅ | ✅ |
| 点击导航菜单(其他模块) | ✅ 正常访问 | ❌ 跳转登录页 |
| 点击管理操作(修复、删除等) | ✅ | ❌ 跳转登录页 |
| 顶部用户信息 | 显示用户名 | 显示"访客" + 登录按钮 |
#### 前端拦截机制
1. **导航菜单拦截**
- 报告页模板接收 `token_auth=True` 标记
- 侧边栏/顶部导航链接绑定点击事件
- 如果 `token_auth` 为 true,点击任何非报告页链接 → 弹出提示"查看更多功能请先登录" → 跳转登录页
2. **管理操作拦截**
- 修复按钮、删除按钮等管理操作,在 `token_auth` 模式下隐藏或禁用
- 导出功能保留(导出是只读操作,不涉及系统修改)
3. **登录入口**
- 报告页顶部显示"访客模式"标识 + "登录查看更多"按钮
- 点击后跳转登录页,登录成功后回到报告页(需支持 `next` 参数)
---
### 2.3 登录后回跳(next 参数)
#### 当前问题
登录成功后固定跳转 `/`,无法回到原始访问页面。
#### 改进方案
1. 未登录访问受保护页面时,重定向到 `/login?next=<原始URL>`
2. 登录成功后,检查 `next` 参数,有则跳转到 `next`,无则跳转 `/`
3. `next` 参数仅允许本站相对路径(防止开放重定向攻击)
#### 涉及改动
- `routes/auth.py`:GET `/login` 读取 `next` 参数传给模板;POST `/login` 成功后返回 `next`
- `login.html`:登录成功后 `window.location.href = next || '/'`
- `service_monitor/routes.py`:未登录重定向时携带 `next` 参数
- `decorators.py``page_login_required` 重定向时携带 `next` 参数
---
## 3. 技术方案
### 3.1 后端改动
| 文件 | 改动 |
|------|------|
| `service_monitor/services/report_service.py` | `save()` 生成 access_token + 过期时间;新增 `validate_access_token()` |
| `service_monitor/services/runner_service.py` | 通知链接拼接 `?token=rpt_xxx` |
| `service_monitor/routes.py` | `page_report()` 支持 token 验证;其他页面路由检查 token_auth 拦截 |
| `routes/auth.py` | 支持 `next` 参数回跳 |
| `decorators.py` | `page_login_required` 重定向携带 `next` |
### 3.2 前端改动
| 文件 | 改动 |
|------|------|
| `templates/service_monitor/report.html` | token_auth 模式:隐藏管理按钮、导航拦截、显示访客标识 |
| `templates/service_monitor/base.html` | 导航菜单 token_auth 拦截逻辑 |
| `templates/login.html` | 支持 `next` 参数回跳 |
### 3.3 数据结构
报告 JSON 新增字段:
```json
{
"id": "20260721_143000_ab12cd",
"access_token": "rpt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"access_token_expires_at": "2026-07-28T14:30:00+08:00",
...原有字段...
}
```
---
## 4. 验收标准
| 标准 | 验证方法 |
|------|---------|
| 钉钉通知链接包含 token 参数 | 检查通知消息中的链接格式 |
| 点击带 token 的链接可直接查看报告 | 未登录状态下点击链接 |
| token 过期后访问跳转登录页 | 修改报告 JSON 中的过期时间为过去时间后访问 |
| 报告被删除后 token 失效 | 删除报告后用原 token 访问 |
| token_auth 模式下点击导航菜单跳转登录页 | 在报告页点击侧边栏其他模块 |
| token_auth 模式下管理操作不可用 | 检查修复/删除按钮是否隐藏 |
| token_auth 模式下导出功能可用 | 点击导出 MD/JSON |
| 登录后回跳到原始页面 | 从报告页跳转登录后回到报告页 |
| next 参数防开放重定向 | 构造 `next=http://evil.com` 验证被拦截 |
| 正常登录用户不受影响 | 登录后访问报告页功能完整 |
---
## 5. 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| token 泄露(钉钉群转发) | 非授权人员可查看报告 | token 7 天过期;报告为只读不涉及修改操作 |
| token 暴力猜测 | 理论上可猜中 token | 32 位 hex = 128 bit 熵,暴力破解不可行 |
| 开放重定向攻击 | next 参数指向恶意站点 | next 仅允许本站相对路径,校验不以 `http` 开头 |
| 旧报告无 token | 已有报告无法免登访问 | 仅新生成的报告有 token,旧报告仍需登录查看 |
| 钉钉 Markdown 链接长度 | URL 变长可能超限 | token 32 字符 + 参数约 40 字符,钉钉限制 2048 字节,远未超限 |
---
## 6. 后续规划
1. **token 管理页面**:管理员可查看/撤销报告 token
2. **邮件通知链接同步**:邮件中的报告链接也携带 token
3. **企业微信通知链接同步**:企业微信通知链接也携带 token
# 需求文档:监测统计模块
## 文档信息
- 创建时间:2026-07-22
- 需求来源:用户需求
- 关联模块:service_monitor/statistics
---
## 1. 背景与目标
### 1.1 背景
服务监测模块已实现:
- 目标服务器管理(本地/远程)
- 巡检报告生成与查看
- 定时任务调度
- 通知推送(钉钉/企业微信/邮件)
用户需要**统计分析和可视化**能力:
- 查看各目标服务器的健康趋势
- 分析检测项的历史变化
- 识别高频异常项
- 按模块维度查看健康度概览
### 1.2 目标
1. 新增左侧菜单"监测统计"
2. 提供可视化统计页面,支持:
- 目标服务器筛选
- 自定义日期范围
- 模块健康概览(堆叠条形图)
- 检测项趋势分析(折线图 + 状态分布饼图)
- 高频异常项排行(水平条形图)
---
## 2. 需求详述
### 2.1 页面入口
**位置**:左侧导航菜单,在"巡检报告"之后、"定时任务"之前
**权限**:所有登录用户可查看(与报告页面一致)
**URL**`/service-monitor/statistics`
---
### 2.2 筛选控制
#### 筛选项
| 筛选项 | 类型 | 说明 |
|--------|------|------|
| 目标服务器 | 下拉选择 | 可选择特定目标或"全部目标" |
| 开始日期 | 日期选择器 | 统计起始日期 |
| 结束日期 | 日期选择器 | 统计结束日期 |
| 查询按钮 | 按钮 | 点击后刷新统计数据 |
**默认值**
- 目标:全部目标
- 开始日期:30 天前
- 结束日期:今天
**交互**
- 修改筛选条件后,需点击"查询"按钮刷新
- 日期范围最大限制:90 天(防止加载过多数据)
---
### 2.3 页面布局
#### 2.3.1 概览摘要卡片(4 卡片网格)
| 卡片 | 内容 |
|------|------|
| 总报告数 | 日期范围内的巡检报告数量 |
| 异常率 | (警告+严重)/总检测项 × 100% |
| 最常异常项 | 异常次数最多的检测项名称 |
| 平均检测项 | 平均每份报告的检测项数量 |
#### 2.3.2 模块健康概览
**图表类型**:堆叠条形图(ECharts)
**维度**
- X 轴:模块名称(按类别分组:系统模块在前,服务模块在后)
- Y 轴:检测项数量
- 系列:正常(绿色)、警告(黄色)、严重(红色)
**交互**
- 鼠标悬停显示:模块名、正常/警告/严重数量、通过率
- 点击某模块可跳转到该模块的详细报告列表
#### 2.3.3 检测项趋势分析
**组成**
1. **检测项选择器**:下拉菜单,列出当前筛选条件下出现过的所有检测项
2. **趋势折线图**(左侧 2/3 宽度):
- X 轴:时间(报告执行时间)
- Y 轴:检测项数值
- 阈值参考线(如有)
- 数据点按状态着色(正常绿、警告黄、严重红)
3. **状态分布饼图**(右侧 1/3 宽度):
- 显示该检测项在日期范围内的状态分布
- 正常、警告、严重占比
**特殊处理**
- 非数值检测项(如"运行中"、"是"):折线图不显示,仅显示饼图
- 某时间点无数据:折线图显示断点
#### 2.3.4 高频异常检测项
**图表类型**:水平堆叠条形图
**维度**
- Y 轴:检测项名称(TOP 10)
- X 轴:异常次数
- 堆叠:警告(黄色)、严重(红色)
**交互**
- 鼠标悬停显示:检测项名、总出现次数、异常次数、异常率
- 点击某检测项自动填充到趋势分析的选择器中
---
## 3. API 设计
| 方法 | URL | 参数 | 说明 |
|------|-----|------|------|
| GET | `/api/service-monitor/statistics/overview` | target_id, start_date, end_date | 概览摘要 |
| GET | `/api/service-monitor/statistics/modules` | target_id, start_date, end_date | 模块统计 |
| GET | `/api/service-monitor/statistics/item-trend` | target_id, item_key, start_date, end_date | 检测项趋势 |
| GET | `/api/service-monitor/statistics/abnormal-items` | target_id, start_date, end_date | 高频异常项 |
| GET | `/api/service-monitor/statistics/item-keys` | target_id, start_date, end_date | 检测项列表(用于选择器) |
**认证**:所有 API 需登录(返回 JSON 401 未登录)
---
## 4. 数据来源
### 4.1 报告数据结构
```json
{
"id": "20260721_100000_abc123",
"target_id": "local",
"target_name": "本机",
"started_at": "2026-07-21T10:00:00",
"summary": {"正常": 42, "警告": 3, "严重": 1, "total": 46},
"modules": [
{
"id": "02_cpu_check",
"name": "CPU检测",
"category": "system",
"items": [
{"key": "CPU_USAGE", "name": "CPU使用率", "value": "92%", "threshold": ">85%", "status": "严重"}
],
"summary": {"正常": 5, "警告": 1, "严重": 0, "total": 6}
}
]
}
```
### 4.2 聚合逻辑
| 指标 | 计算方式 |
|------|---------|
| 总报告数 | 统计日期范围内的报告数量 |
| 异常率 | sum(警告+严重) / sum(总检测项) × 100% |
| 模块健康度 | 按模块 ID 汇总正常/警告/严重计数 |
| 检测项趋势 | 扫描每份报告,提取指定 item_key 的值和状态 |
| 高频异常项 | 按 item_key 统计异常次数,降序取 TOP 10 |
---
## 5. 技术方案
### 5.1 后端
| 文件 | 改动 |
|------|------|
| `service_monitor/services/statistics_service.py` | 新增 - 统计聚合服务 |
| `service_monitor/services/__init__.py` | 导入新服务 |
| `service_monitor/routes.py` | 新增页面路由 + 5 个 API 端点 |
### 5.2 前端
| 文件 | 改动 |
|------|------|
| `templates/service_monitor/base.html` | 侧边栏添加"监测统计"菜单项 |
| `templates/service_monitor/statistics.html` | 新增 - 统计页面模板 |
### 5.3 图表库
**ECharts 5.5.1**(CDN 引入):
- 堆叠条形图
- 折线图 + 标记点
- 饼图
- 响应式适配
---
## 6. 验收标准
| 标准 | 验证方法 |
|------|---------|
| 左侧菜单显示"监测统计" | 页面检查 |
| 页面正常加载,显示筛选栏和图表区域 | 页面检查 |
| 目标选择器列出所有目标 | 页面检查 |
| 日期选择器默认为近 30 天 | 页面检查 |
| 概览卡片显示正确数据 | 对比报告数据计算 |
| 模块健康图表正常渲染 | 检查 ECharts 图表 |
| 检测项选择器列出所有检测项 | 页面检查 |
| 选择检测项后趋势图正常显示 | 选择后检查 |
| 高频异常项图表正确显示 | 对比报告数据 |
| API 返回格式正确 | curl 测试 |
| 未登录访问 API 返回 401 | 未登录状态测试 |
| 单元测试通过 | pytest 运行 |
---
## 7. 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 报告数据量大时加载慢 | 页面响应时间长 | 默认 30 天范围;最大 200 份报告上限;两步加载(先摘要后详情) |
| 检测项键名不一致 | 趋势图数据缺失 | 动态扫描实际存在的键;选择器只显示有数据的项 |
| 非数值检测项无法绘图 | 折线图空白 | 跳过数值提取失败的点;饼图仍显示状态分布 |
| ECharts CDN 加载失败 | 图表不显示 | 使用稳定 CDN(jsdelivr);本地缓存 |
---
## 8. 后续规划
1. **导出统计报告**:支持导出 PDF/图片格式的统计报告
2. **告警阈值配置**:允许用户配置统计告警阈值
3. **趋势预测**:基于历史数据进行趋势预测
4. **对比分析**:多目标对比分析
\ No newline at end of file
此差异已折叠。
...@@ -42,11 +42,12 @@ def admin_required(f): ...@@ -42,11 +42,12 @@ def admin_required(f):
def page_login_required(f): def page_login_required(f):
""" """
页面级登录验证装饰器(用于页面路由) 页面级登录验证装饰器(用于页面路由)
未登录时重定向到登录页,而不是返回 JSON 未登录时重定向到登录页,携带 next 参数
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if 'user' not in session: if 'user' not in session:
return redirect(url_for('auth.login')) next_url = request.url
return redirect(url_for('auth.login', next=next_url))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
\ No newline at end of file
...@@ -27,10 +27,15 @@ bp = Blueprint('auth', __name__) ...@@ -27,10 +27,15 @@ bp = Blueprint('auth', __name__)
def login(): def login():
"""登录页面""" """登录页面"""
if request.method == 'GET': if request.method == 'GET':
# 已登录则直接跳转主页 # 已登录则直接跳转
if 'user' in session: if 'user' in session:
return redirect('/') next_url = request.args.get('next', '/')
return render_template('login.html') # 安全校验:仅允许本站相对路径
if not next_url.startswith('/') or next_url.startswith('//'):
next_url = '/'
return redirect(next_url)
next_url = request.args.get('next', '')
return render_template('login.html', next_url=next_url)
# POST 请求:处理登录 # POST 请求:处理登录
data = request.get_json() or {} data = request.get_json() or {}
...@@ -65,9 +70,16 @@ def login(): ...@@ -65,9 +70,16 @@ def login():
'result': 'success' 'result': 'success'
}) })
# next 参数回跳
next_url = request.args.get('next') or data.get('next') or '/'
# 安全校验:仅允许本站相对路径
if not next_url.startswith('/') or next_url.startswith('//'):
next_url = '/'
return jsonify({ return jsonify({
'success': True, 'success': True,
'user': session['user'] 'user': session['user'],
'next': next_url,
}) })
else: else:
# 记录失败日志 # 记录失败日志
......
...@@ -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 from .services import target_service, report_service, runner_service, schedule_service, notification_service, statistics_service
logger = logging.getLogger("service_monitor.routes") logger = logging.getLogger("service_monitor.routes")
...@@ -153,6 +153,21 @@ def page_notification(): ...@@ -153,6 +153,21 @@ def page_notification():
) )
@bp.route('/service-monitor/statistics')
def page_statistics():
"""监测统计页(所有登录用户可看)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
targets = target_service.list_targets(role=user.get('role', ''))
return render_template(
'service_monitor/statistics.html',
user=user, targets=targets,
is_admin=(user.get('role') == 'admin'),
active_menu='statistics',
)
@bp.route('/service-monitor/run/<target_id>') @bp.route('/service-monitor/run/<target_id>')
def page_run(target_id): def page_run(target_id):
"""巡检执行页(仅管理员)。""" """巡检执行页(仅管理员)。"""
...@@ -172,9 +187,29 @@ def page_run(target_id): ...@@ -172,9 +187,29 @@ def page_run(target_id):
@bp.route('/service-monitor/report/<report_id>') @bp.route('/service-monitor/report/<report_id>')
def page_report(report_id): def page_report(report_id):
"""报告详情页(所有登录用户可看)。""" """报告详情页(支持 token 免登访问)。"""
token = request.args.get('token', '').strip()
# 1. 尝试 token 免登访问
if token and report_service.validate_access_token(report_id, token):
report = report_service.get_report(report_id)
if not report:
return redirect(url_for('auth.login'))
return render_template(
'service_monitor/report.html',
user=None, # 无登录用户
report=report,
is_admin=False,
active_menu='reports',
token_auth=True, # 标记访客模式
access_token=token, # 传递 token 用于导出等只读操作
)
# 2. 常规登录访问
if 'user' not in session: if 'user' not in session:
return redirect(url_for('auth.login')) # 携带 next 参数,登录后回到报告页
next_url = request.url # 完整 URL(含 token)
return redirect(url_for('auth.login', next=next_url))
user = _current_user() user = _current_user()
report = report_service.get_report(report_id) report = report_service.get_report(report_id)
if not report: if not report:
...@@ -183,6 +218,7 @@ def page_report(report_id): ...@@ -183,6 +218,7 @@ def page_report(report_id):
'service_monitor/report.html', user=user, report=report, 'service_monitor/report.html', user=user, report=report,
is_admin=(user.get('role') == 'admin'), is_admin=(user.get('role') == 'admin'),
active_menu='reports', active_menu='reports',
token_auth=False,
) )
...@@ -346,6 +382,27 @@ def api_batch_delete_reports(): ...@@ -346,6 +382,27 @@ def api_batch_delete_reports():
@bp.route('/api/service-monitor/reports/<report_id>/export', methods=['GET']) @bp.route('/api/service-monitor/reports/<report_id>/export', methods=['GET'])
def api_export_report(report_id): def api_export_report(report_id):
# token 免登导出(只读操作,允许访客)
token = request.args.get('token', '').strip()
if token and report_service.validate_access_token(report_id, token):
fmt = request.args.get('format', 'md')
if fmt == 'json':
data = report_service.export_json(report_id)
if not data:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
resp = Response(json.dumps(data, ensure_ascii=False, indent=2),
mimetype='application/json')
resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.json'
return resp
else:
md = report_service.export_markdown(report_id)
if md is None:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
resp = Response(md, mimetype='text/markdown')
resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.md'
return resp
# 常规登录访问
guard = _require_login_json() guard = _require_login_json()
if guard: if guard:
return guard return guard
...@@ -520,3 +577,75 @@ def api_test_wecom(): ...@@ -520,3 +577,75 @@ def api_test_wecom():
return guard return guard
result = notification_service.test_wecom() result = notification_service.test_wecom()
return jsonify(result) return jsonify(result)
# ============================================================
# API:监测统计(登录即可查看)
# ============================================================
@bp.route('/api/service-monitor/statistics/overview', methods=['GET'])
def api_statistics_overview():
"""概览统计。"""
guard = _require_login_json()
if guard:
return guard
target_id = request.args.get('target_id') or None
start_date = request.args.get('start_date') or None
end_date = request.args.get('end_date') or None
data = statistics_service.get_overview(target_id, start_date, end_date)
return jsonify({"success": True, "data": data})
@bp.route('/api/service-monitor/statistics/modules', methods=['GET'])
def api_statistics_modules():
"""模块健康统计。"""
guard = _require_login_json()
if guard:
return guard
target_id = request.args.get('target_id') or None
start_date = request.args.get('start_date') or None
end_date = request.args.get('end_date') or None
data = statistics_service.get_module_stats(target_id, start_date, end_date)
return jsonify({"success": True, "data": data})
@bp.route('/api/service-monitor/statistics/item-trend', methods=['GET'])
def api_statistics_item_trend():
"""检测项趋势。"""
guard = _require_login_json()
if guard:
return guard
target_id = request.args.get('target_id') or None
item_key = request.args.get('item_key', '')
start_date = request.args.get('start_date') or None
end_date = request.args.get('end_date') or None
if not item_key:
return jsonify({"success": False, "error": {"code": 400, "message": "缺少 item_key 参数"}}), 400
data = statistics_service.get_item_trend(target_id, item_key, start_date, end_date)
return jsonify({"success": True, "data": data})
@bp.route('/api/service-monitor/statistics/abnormal-items', methods=['GET'])
def api_statistics_abnormal_items():
"""高频异常检测项。"""
guard = _require_login_json()
if guard:
return guard
target_id = request.args.get('target_id') or None
start_date = request.args.get('start_date') or None
end_date = request.args.get('end_date') or None
data = statistics_service.get_abnormal_items(target_id, start_date, end_date)
return jsonify({"success": True, "data": data})
@bp.route('/api/service-monitor/statistics/item-keys', methods=['GET'])
def api_statistics_item_keys():
"""检测项键列表(用于选择器)。"""
guard = _require_login_json()
if guard:
return guard
target_id = request.args.get('target_id') or None
start_date = request.args.get('start_date') or None
end_date = request.args.get('end_date') or None
data = statistics_service.get_item_keys_for_target(target_id, start_date, end_date)
return jsonify({"success": True, "data": data})
...@@ -4,3 +4,4 @@ from . import target_service ...@@ -4,3 +4,4 @@ from . import target_service
from . import report_service from . import report_service
from . import runner_service from . import runner_service
from . import schedule_service from . import schedule_service
from . import statistics_service
...@@ -10,10 +10,17 @@ from __future__ import annotations ...@@ -10,10 +10,17 @@ from __future__ import annotations
import json import json
import logging import logging
import secrets
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional from typing import List, Optional
try:
from pytz import timezone as pytz_timezone
SHANGHAI_TZ = pytz_timezone("Asia/Shanghai")
except ImportError:
SHANGHAI_TZ = None
from ..utils.paths import REPORTS_DIR, ensure_dirs from ..utils.paths import REPORTS_DIR, ensure_dirs
logger = logging.getLogger("service_monitor.report_service") logger = logging.getLogger("service_monitor.report_service")
...@@ -21,6 +28,9 @@ logger = logging.getLogger("service_monitor.report_service") ...@@ -21,6 +28,9 @@ logger = logging.getLogger("service_monitor.report_service")
# 报告默认保留天数(可被配置覆盖) # 报告默认保留天数(可被配置覆盖)
DEFAULT_RETENTION_DAYS = 14 DEFAULT_RETENTION_DAYS = 14
# Token 访问有效期(天)
ACCESS_TOKEN_DAYS = 7
NORMAL, WARNING, CRITICAL = "正常", "警告", "严重" NORMAL, WARNING, CRITICAL = "正常", "警告", "严重"
...@@ -52,6 +62,10 @@ def save(target: dict, suite: str, modules_result: list, ...@@ -52,6 +62,10 @@ def save(target: dict, suite: str, modules_result: list,
if st in total: if st in total:
total[st] += 1 total[st] += 1
# Token 过期时间
now = datetime.now(SHANGHAI_TZ) if SHANGHAI_TZ else datetime.now()
expires_at = (now + timedelta(days=ACCESS_TOKEN_DAYS)).isoformat()
report = { report = {
"id": report_id, "id": report_id,
"target_id": target.get("id"), "target_id": target.get("id"),
...@@ -62,6 +76,8 @@ def save(target: dict, suite: str, modules_result: list, ...@@ -62,6 +76,8 @@ def save(target: dict, suite: str, modules_result: list,
"finished_at": finished_at or _now(), "finished_at": finished_at or _now(),
"summary": total, "summary": total,
"modules": modules_result, "modules": modules_result,
"access_token": f"rpt_{secrets.token_hex(32)}",
"access_token_expires_at": expires_at,
} }
_report_path(report_id).write_text( _report_path(report_id).write_text(
...@@ -203,3 +219,48 @@ def export_markdown(report_id: str) -> Optional[str]: ...@@ -203,3 +219,48 @@ def export_markdown(report_id: str) -> Optional[str]:
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
# ============================================================
# Token 验证
# ============================================================
def validate_access_token(report_id: str, token: str) -> bool:
"""验证报告访问 token 是否有效。
Args:
report_id: 报告 ID
token: 访问 token(rpt_xxx 格式)
Returns:
True=token 有效,False=无效或过期
"""
if not token or not token.startswith("rpt_"):
return False
report = get_report(report_id)
if not report:
return False
stored_token = report.get("access_token", "")
if not stored_token or stored_token != token:
return False
expires_at_str = report.get("access_token_expires_at", "")
if not expires_at_str:
return False
try:
expires_at = datetime.fromisoformat(expires_at_str)
now = datetime.now(SHANGHAI_TZ) if SHANGHAI_TZ else datetime.now()
# 如果 expires_at 无时区信息,假设为本地时间
if expires_at.tzinfo is None and now.tzinfo is not None:
expires_at = expires_at.replace(tzinfo=now.tzinfo)
elif expires_at.tzinfo is not None and now.tzinfo is None:
now = now.replace(tzinfo=expires_at.tzinfo)
if now > expires_at:
return False
except (ValueError, TypeError):
return False
return True
...@@ -277,6 +277,9 @@ def run_inspection_sync(target_id: str, suite: str) -> dict: ...@@ -277,6 +277,9 @@ def run_inspection_sync(target_id: str, suite: str) -> dict:
import os import os
host = os.environ.get('EXTERNAL_HOST', 'http://192.168.5.60:8088') host = os.environ.get('EXTERNAL_HOST', 'http://192.168.5.60:8088')
report_url = f"{host}/service-monitor/report/{report_id}" report_url = f"{host}/service-monitor/report/{report_id}"
# 拼接访问 token,实现免登查看
if report.get("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)
except Exception as e: except Exception as e:
logger.warning("发送通知失败: %s", e) logger.warning("发送通知失败: %s", e)
......
...@@ -180,6 +180,8 @@ ...@@ -180,6 +180,8 @@
<div class="login-body"> <div class="login-body">
<form id="loginForm"> <form id="loginForm">
<!-- next 参数:登录后回跳 -->
<input type="hidden" id="nextUrl" value="{{ next_url or '' }}">
<div class="form-group"> <div class="form-group">
<label class="form-label">用户名</label> <label class="form-label">用户名</label>
<input type="text" id="username" <input type="text" id="username"
...@@ -219,6 +221,18 @@ ...@@ -219,6 +221,18 @@
</div> </div>
<script> <script>
// 获取 next 参数
function getNextUrl() {
// 优先从隐藏字段获取(服务端传递)
const hiddenNext = document.getElementById('nextUrl');
if (hiddenNext && hiddenNext.value) {
return hiddenNext.value;
}
// 其次从 URL 参数获取
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('next') || '/';
}
// 登录表单提交 // 登录表单提交
document.getElementById('loginForm').addEventListener('submit', async (e) => { document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
...@@ -226,6 +240,7 @@ ...@@ -226,6 +240,7 @@
const username = document.getElementById('username').value.trim(); const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const remember = document.getElementById('remember').checked; const remember = document.getElementById('remember').checked;
const nextUrl = getNextUrl();
if (!username || !password) { if (!username || !password) {
showError('请输入用户名和密码'); showError('请输入用户名和密码');
...@@ -242,15 +257,16 @@ ...@@ -242,15 +257,16 @@
const resp = await fetch('/login', { const resp = await fetch('/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, remember }), body: JSON.stringify({ username, password, remember, next: nextUrl }),
credentials: 'include' credentials: 'include'
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
// 登录成功,跳转主页 // 登录成功,跳转到 next 或主页
window.location.href = '/'; const redirectUrl = data.next || nextUrl || '/';
window.location.href = redirectUrl;
} else { } else {
showError(errMsg(data.error) || '登录失败'); showError(errMsg(data.error) || '登录失败');
loginBtn.textContent = originalText; loginBtn.textContent = originalText;
......
...@@ -219,6 +219,10 @@ ...@@ -219,6 +219,10 @@
<span class="nav-icon">📋</span> <span class="nav-icon">📋</span>
<span>巡检报告</span> <span>巡检报告</span>
</a> </a>
<a href="/service-monitor/statistics" class="nav-item {{ 'active' if active_menu == 'statistics' }}">
<span class="nav-icon">📈</span>
<span>监测统计</span>
</a>
<a href="/service-monitor/schedule" class="nav-item {{ 'active' if active_menu == 'schedule' }}"> <a href="/service-monitor/schedule" class="nav-item {{ 'active' if active_menu == 'schedule' }}">
<span class="nav-icon"></span> <span class="nav-icon"></span>
<span>定时任务</span> <span>定时任务</span>
...@@ -240,11 +244,20 @@ ...@@ -240,11 +244,20 @@
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<button class="hamburger" id="hamburger" onclick="toggleSidebar()"></button> <button class="hamburger" id="hamburger" onclick="toggleSidebar()"></button>
{% if token_auth %}
<a href="/login?next={{ request.url | urlencode }}" class="topbar-home">🏠 返回首页</a>
{% else %}
<a href="/" class="topbar-home">🏠 返回首页</a> <a href="/" class="topbar-home">🏠 返回首页</a>
{% endif %}
</div> </div>
<div class="topbar-user"> <div class="topbar-user">
{% if token_auth %}
<span>👤 访客</span>
<a href="/login?next={{ request.url | urlencode }}" class="btn-logout" style="text-decoration:none;">🔑 登录</a>
{% else %}
<span>{{ user.username }}({{ '管理员' if is_admin else '用户' }})</span> <span>{{ user.username }}({{ '管理员' if is_admin else '用户' }})</span>
<button class="btn-logout" onclick="handleLogout()">🚪 退出</button> <button class="btn-logout" onclick="handleLogout()">🚪 退出</button>
{% endif %}
</div> </div>
</header> </header>
...@@ -283,6 +296,21 @@ ...@@ -283,6 +296,21 @@
} catch (e) {} } catch (e) {}
window.location.href = '/login'; window.location.href = '/login';
} }
{% if token_auth %}
// 访客模式:点击导航菜单跳转登录页
(function() {
var loginUrl = '/login?next=' + encodeURIComponent(window.location.href);
document.querySelectorAll('.sidebar-nav .nav-item').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
if (confirm('查看更多功能请先登录,是否前往登录页?')) {
window.location.href = loginUrl;
}
});
});
})();
{% endif %}
</script> </script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
......
...@@ -5,6 +5,34 @@ ...@@ -5,6 +5,34 @@
<style> <style>
.container { max-width: 960px; } .container { max-width: 960px; }
/* 访客模式提示条 */
.guest-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 16px;
font-size: 14px;
color: #92400e;
flex-wrap: wrap;
gap: 10px;
}
.guest-banner span { display: flex; align-items: center; gap: 6px; }
.btn-login-link {
background: #2563eb;
color: #fff;
padding: 6px 14px;
border-radius: 6px;
text-decoration: none;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.btn-login-link:hover { background: #1d4ed8; }
/* 报告头 */ /* 报告头 */
.report-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 12px; } .report-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 12px; }
.report-meta h2 { font-size: 20px; color: var(--gray-900); margin-bottom: 4px; } .report-meta h2 { font-size: 20px; color: var(--gray-900); margin-bottom: 4px; }
...@@ -91,6 +119,13 @@ ...@@ -91,6 +119,13 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
{% if token_auth %}
<div class="guest-banner">
<span>👤 访客模式 — 仅可查看本报告</span>
<a href="/login?next={{ request.url | urlencode }}" class="btn-login-link">🔑 登录查看更多功能</a>
</div>
{% endif %}
<div class="report-head"> <div class="report-head">
<div class="report-meta"> <div class="report-meta">
<h2>{{ '🖥️' if report.target_type == 'local' else '🌐' }} {{ report.target_name }}</h2> <h2>{{ '🖥️' if report.target_type == 'local' else '🌐' }} {{ report.target_name }}</h2>
...@@ -102,8 +137,8 @@ ...@@ -102,8 +137,8 @@
<div class="export-wrap"> <div class="export-wrap">
<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">导出 Markdown</a> <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=json">导出 JSON</a> <a href="/api/service-monitor/reports/{{ report.id }}/export?format=json{% if token_auth %}&token={{ access_token }}{% endif %}">导出 JSON</a>
</div> </div>
</div> </div>
</div> </div>
...@@ -137,7 +172,7 @@ ...@@ -137,7 +172,7 @@
<span class="ab-value {{ 'ab-crit' if it.status == '严重' else 'ab-warn' }}">{{ it.value }}</span> <span class="ab-value {{ 'ab-crit' if it.status == '严重' else 'ab-warn' }}">{{ it.value }}</span>
<span class="ab-threshold">{{ it.threshold or '-' }}</span> <span class="ab-threshold">{{ it.threshold or '-' }}</span>
<span class="item-status stt-{{ it.status }}">{{ it.status }}</span> <span class="item-status stt-{{ it.status }}">{{ it.status }}</span>
{% if is_admin %} {% if is_admin and not token_auth %}
<button class="btn-fix" onclick="tryFix('{{ it.key }}')">修复</button> <button class="btn-fix" onclick="tryFix('{{ it.key }}')">修复</button>
{% else %} {% else %}
<span></span> <span></span>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论