提交 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):
def page_login_required(f):
"""
页面级登录验证装饰器(用于页面路由)
未登录时重定向到登录页,而不是返回 JSON
未登录时重定向到登录页,携带 next 参数
"""
@wraps(f)
def decorated_function(*args, **kwargs):
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 decorated_function
\ No newline at end of file
......@@ -27,10 +27,15 @@ bp = Blueprint('auth', __name__)
def login():
"""登录页面"""
if request.method == 'GET':
# 已登录则直接跳转主页
# 已登录则直接跳转
if 'user' in session:
return redirect('/')
return render_template('login.html')
next_url = request.args.get('next', '/')
# 安全校验:仅允许本站相对路径
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 请求:处理登录
data = request.get_json() or {}
......@@ -65,9 +70,16 @@ def login():
'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({
'success': True,
'user': session['user']
'user': session['user'],
'next': next_url,
})
else:
# 记录失败日志
......
......@@ -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
from .services import target_service, report_service, runner_service, schedule_service, notification_service, statistics_service
logger = logging.getLogger("service_monitor.routes")
......@@ -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>')
def page_run(target_id):
"""巡检执行页(仅管理员)。"""
......@@ -172,9 +187,29 @@ def page_run(target_id):
@bp.route('/service-monitor/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:
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()
report = report_service.get_report(report_id)
if not report:
......@@ -183,6 +218,7 @@ def page_report(report_id):
'service_monitor/report.html', user=user, report=report,
is_admin=(user.get('role') == 'admin'),
active_menu='reports',
token_auth=False,
)
......@@ -346,6 +382,27 @@ def api_batch_delete_reports():
@bp.route('/api/service-monitor/reports/<report_id>/export', methods=['GET'])
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()
if guard:
return guard
......@@ -520,3 +577,75 @@ def api_test_wecom():
return guard
result = notification_service.test_wecom()
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
from . import report_service
from . import runner_service
from . import schedule_service
from . import statistics_service
......@@ -10,10 +10,17 @@ from __future__ import annotations
import json
import logging
import secrets
import uuid
from datetime import datetime, timedelta
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
logger = logging.getLogger("service_monitor.report_service")
......@@ -21,6 +28,9 @@ logger = logging.getLogger("service_monitor.report_service")
# 报告默认保留天数(可被配置覆盖)
DEFAULT_RETENTION_DAYS = 14
# Token 访问有效期(天)
ACCESS_TOKEN_DAYS = 7
NORMAL, WARNING, CRITICAL = "正常", "警告", "严重"
......@@ -52,6 +62,10 @@ def save(target: dict, suite: str, modules_result: list,
if st in total:
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 = {
"id": report_id,
"target_id": target.get("id"),
......@@ -62,6 +76,8 @@ def save(target: dict, suite: str, modules_result: list,
"finished_at": finished_at or _now(),
"summary": total,
"modules": modules_result,
"access_token": f"rpt_{secrets.token_hex(32)}",
"access_token_expires_at": expires_at,
}
_report_path(report_id).write_text(
......@@ -203,3 +219,48 @@ def export_markdown(report_id: str) -> Optional[str]:
lines.append("")
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:
import os
host = os.environ.get('EXTERNAL_HOST', 'http://192.168.5.60:8088')
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)
except Exception as e:
logger.warning("发送通知失败: %s", e)
......
......@@ -180,6 +180,8 @@
<div class="login-body">
<form id="loginForm">
<!-- next 参数:登录后回跳 -->
<input type="hidden" id="nextUrl" value="{{ next_url or '' }}">
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" id="username"
......@@ -219,6 +221,18 @@
</div>
<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) => {
e.preventDefault();
......@@ -226,6 +240,7 @@
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const remember = document.getElementById('remember').checked;
const nextUrl = getNextUrl();
if (!username || !password) {
showError('请输入用户名和密码');
......@@ -242,15 +257,16 @@
const resp = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, remember }),
body: JSON.stringify({ username, password, remember, next: nextUrl }),
credentials: 'include'
});
const data = await resp.json();
if (data.success) {
// 登录成功,跳转主页
window.location.href = '/';
// 登录成功,跳转到 next 或主页
const redirectUrl = data.next || nextUrl || '/';
window.location.href = redirectUrl;
} else {
showError(errMsg(data.error) || '登录失败');
loginBtn.textContent = originalText;
......
......@@ -219,6 +219,10 @@
<span class="nav-icon">📋</span>
<span>巡检报告</span>
</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' }}">
<span class="nav-icon"></span>
<span>定时任务</span>
......@@ -240,11 +244,20 @@
<header class="topbar">
<div class="topbar-left">
<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>
{% endif %}
</div>
<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>
<button class="btn-logout" onclick="handleLogout()">🚪 退出</button>
{% endif %}
</div>
</header>
......@@ -283,6 +296,21 @@
} catch (e) {}
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>
{% block extra_js %}{% endblock %}
</body>
......
......@@ -5,6 +5,34 @@
<style>
.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-meta h2 { font-size: 20px; color: var(--gray-900); margin-bottom: 4px; }
......@@ -91,6 +119,13 @@
{% block content %}
<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-meta">
<h2>{{ '🖥️' if report.target_type == 'local' else '🌐' }} {{ report.target_name }}</h2>
......@@ -102,8 +137,8 @@
<div class="export-wrap">
<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">导出 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=md{% if token_auth %}&token={{ access_token }}{% endif %}">导出 Markdown</a>
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=json{% if token_auth %}&token={{ access_token }}{% endif %}">导出 JSON</a>
</div>
</div>
</div>
......@@ -137,7 +172,7 @@
<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="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>
{% else %}
<span></span>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论