提交 9d90d646 authored 作者: 陈泽健's avatar 陈泽健

fix(service-monitor): 定时任务不执行修复 + 依赖补充 + 报告批量删除

修复:
- server.py FLASK_DEBUG 默认值改为 0(避免 reloader 导致 APScheduler 失效)
- schedule_service init_scheduler 增加 WERKZEUG_RUN_MAIN reloader 防护
- health 接口增加 scheduler 状态字段便于诊断
- requirements.txt 补充 apscheduler + croniter 依赖声明
- upload_to_server.py 启动命令加 FLASK_DEBUG=0

新增:
- 巡检报告批量删除(checkbox + batch API)
- 定时任务编辑保存成功提示
- PRD 问题处理与计划执行文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 fdb0ee63
# 计划执行 — 定时任务与巡检报告交互优化
> 版本:1.0 | 日期:2026-07-21 | 关联:PRD_问题处理_定时任务与报告优化.md
---
## 步骤 1:修复定时任务不执行(P0)
### 1.1 server.py — 避免 reloader 重复初始化
**文件**`skill/code/web/service_monitor/services/schedule_service.py`
`init_scheduler()` 增加 reloader 防护:
```python
def init_scheduler(app) -> None:
global _scheduler
with _scheduler_lock:
if _scheduler is not None:
return
# Werkzeug reloader 防护:仅在主进程或非 reloader 模式下初始化
import os
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
# debug 模式下父进程是 reloader 监控进程,跳过避免双进程
# 生产模式(debug=False)此判断不影响,因为没有 reloader
...
```
**更稳妥方案**:用 `use_reloader=False` 替代 debug 控制,或确保 reloader 子进程初始化。
实际采用:检测 `WERKZEUG_RUN_MAIN == 'true'`(子进程)或 debug=False 时才初始化。
### 1.2 systemd 服务配置
**文件**`config/troubleshoot.service`(或部署时远程写入)
增加:
```
Environment=FLASK_DEBUG=0
```
---
## 步骤 2:编辑保存提示(P2)
**文件**`templates/service_monitor/schedule.html`
`saveSchedule()` 成功后:
```javascript
if (d.success) {
alert('保存成功');
closeModal();
location.reload();
}
```
---
## 步骤 3:定时任务执行状态显示(P2)
### 3.1 后端 schedule_service.py
`_execute_scheduled_job` 开始时更新 `running` 状态:
```python
def _execute_scheduled_job(schedule_id):
update_run_status_partial(schedule_id, status="running") # 新增
...
```
新增字段 `current_status`(idle/running/success/failed)。
### 3.2 前端 schedule.html
- 列表增加"执行状态"列,用颜色 badge 显示
- 页面每 30 秒自动轮询刷新(仅当有 running 任务时)
---
## 步骤 4:批量删除报告(P2)
### 4.1 后端 routes.py
新增:
```python
@bp.route('/api/service-monitor/reports/batch', methods=['DELETE'])
def api_batch_delete_reports():
ids = request.json.get('ids', [])
deleted = [rid for rid in ids if report_service.delete_report(rid)]
return jsonify({"success": True, "deleted": len(deleted)})
```
### 4.2 前端 reports.html
- 每行 checkbox
- 表头全选 checkbox
- "删除选中"按钮
---
## 步骤 5:部署验证
1. 修改 systemd 配置重启服务
2. 验证 APScheduler 在服务进程中正常初始化
3. 创建一个 2 分钟后的定时任务,验证到点执行并生成报告
4. 验证编辑提示、执行状态显示、批量删除
---
## 风险与兜底
| 风险 | 兜底 |
|------|------|
| reloader 防护判断错误导致 scheduler 不初始化 | 同时用 systemd FLASK_DEBUG=0 双保险 |
| 轮询刷新消耗资源 | 仅当有 running 任务时轮询,30 秒间隔 |
| 批量删除误删 | 前端二次确认 |
# 问题处理 — 定时任务与巡检报告交互优化
> 版本:1.0 | 日期:2026-07-21 | 作者:czj
---
## 问题描述
| # | 问题 | 严重度 |
|---|------|--------|
| 1 | 编辑定时任务后无"保存成功"提示 | P2 体验 |
| 2 | 定时任务执行时页面无执行状态感知 | P2 体验 |
| 3 | 巡检报告列表缺少批量删除功能 | P2 功能 |
| 4 | 定时任务到时间后不执行、不输出报告 | P0 功能 |
---
## 问题 4 根因分析(P0)
### 现象
设置了定时任务(如 5.44 全量巡检),到了指定时间后:
- 巡检报告未生成
- `schedules.json``last_run_at` 始终为 `null`
- APScheduler 在服务进程中为 `None`
### 根因
**根因 1:Flask debug 模式的 reloader 机制**
`server.py``debug = os.environ.get('FLASK_DEBUG', '1') == '1'` 默认为 `True`
Flask debug 模式使用 Werkzeug reloader,会启动两个进程:
- **父进程(reloader 监控进程)**`init_scheduler()` 在此执行,APScheduler 正常初始化
- **子进程(实际服务进程)**:reloader fork 出的新进程,`init_scheduler()` 中的 `_scheduler is not None` 检查通过,但实际是新进程的全新全局变量 `_scheduler = None`
结果:APScheduler 运行在即将被丢弃的父进程中,实际处理请求的子进程中 scheduler 为 None。
**根因 2:start_date/end_date 类型错误**(已修复)
APScheduler 的 `CronTrigger.start_date` 需要 `datetime` 对象,不能直接赋值字符串。传字符串会报 `'str' object has no attribute 'astimezone'`,传无时区 datetime 会报 `can't compare offset-naive and offset-aware datetimes`
**根因 3:croniter 依赖缺失**(已修复)
### 修复方案
| 根因 | 修复 |
|------|------|
| Flask debug reloader | 1. systemd 服务加 `Environment=FLASK_DEBUG=0`;2. `init_scheduler()` 检查 `WERKZEUG_RUN_MAIN` 环境变量,仅在 reloader 子进程中初始化 |
| start_date 类型错误 | 转为 `datetime` + `timezone.utc`(已修复) |
| croniter 缺失 | requirements.txt 补充(已修复) |
---
## 问题 1:编辑保存后无提示
### 现象
编辑定时任务,点击保存,弹窗关闭、页面刷新,但没有"保存成功"提示。
### 原因
`saveSchedule()` 中成功时只执行 `closeModal() + location.reload()`,无用户提示。
### 修复
保存成功后先 `alert('保存成功')` 再刷新,或使用 toast 提示。
---
## 问题 2:定时任务执行状态不可见
### 现象
定时任务到了执行时间后,页面上无法看到是否正在执行,只能等几分钟后查看巡检报告是否产生。
### 修复方案
在定时任务列表中增加**执行状态**列:
- `idle`(空闲):从未执行或上次已完成
- `running`(执行中):正在执行巡检
- `success`(成功):上次执行成功,显示报告链接
- `failed`(失败):上次执行失败
后端 `schedule_service``_execute_scheduled_job` 开始/结束时更新状态。
前端定时轮询刷新列表(每 30 秒),或在列表页显示 `last_run_status`
---
## 问题 3:批量删除报告
### 现象
巡检报告列表只能逐条删除,无法批量选择删除。
### 修复方案
1. 报告列表每行增加 checkbox
2. 表头增加全选 checkbox
3. 底部增加"删除选中"按钮
4. 新增 API `DELETE /api/service-monitor/reports/batch` 接收 `{ids: []}`
...@@ -68,7 +68,7 @@ _EXCLUDE_SUFFIXES = ('.pyc', '.pyo') ...@@ -68,7 +68,7 @@ _EXCLUDE_SUFFIXES = ('.pyc', '.pyo')
_UNIX_LINE_END_SUFFIXES = ('.sh', '.template') _UNIX_LINE_END_SUFFIXES = ('.sh', '.template')
# 服务监测模块需要的 Python 依赖(SSH 远程执行 + 定时调度) # 服务监测模块需要的 Python 依赖(SSH 远程执行 + 定时调度)
_REQUIRED_PACKAGES = ['paramiko', 'cryptography', 'apscheduler'] _REQUIRED_PACKAGES = ['paramiko', 'cryptography', 'apscheduler', 'croniter']
def _check_and_install_deps(ssh): def _check_and_install_deps(ssh):
...@@ -232,15 +232,37 @@ def upload_files(): ...@@ -232,15 +232,37 @@ def upload_files():
print("\n[OK] Files uploaded") print("\n[OK] Files uploaded")
print("\n[5/5] Restarting service...") print("\n[5/5] Restarting service...")
restart_cmd = ( # 优先用 systemctl 管理(生产标准方式),自动确保 FLASK_DEBUG=0
'cd /opt/troubleshoot/web && ' # 若 service 文件缺少 FLASK_DEBUG=0,则注入后再 daemon-reload
'pkill -f "python.*server.py" || true && ' ensure_service_cmd = (
'sleep 2 && ' 'svc=/etc/systemd/system/troubleshoot.service; '
'nohup python3 server.py > /tmp/troubleshoot.log 2>&1 &' 'if [ -f "$svc" ] && ! grep -q "FLASK_DEBUG=0" "$svc"; then '
' sudo sed -i "/^ExecStart=/i Environment=FLASK_DEBUG=0" "$svc" 2>/dev/null '
' || sed -i "/^ExecStart=/i Environment=FLASK_DEBUG=0" "$svc"; '
' sudo systemctl daemon-reload 2>/dev/null || true; '
'fi; '
'echo ok'
) )
stdin, stdout, stderr = ssh.exec_command(restart_cmd) stdin, stdout, stderr = ssh.exec_command(ensure_service_cmd)
stdout.read() stdout.read()
print("[OK] Service restarted")
# 尝试 systemctl 重启(需要 sudo 或已配置);失败则回退到 nohup
stdin, stdout, stderr = ssh.exec_command('sudo -n systemctl restart troubleshoot 2>/dev/null && echo SYSTEMD_OK || echo NO_SYSTEMD')
restart_result = stdout.read().decode().strip()
if 'SYSTEMD_OK' in restart_result:
print("[OK] Service restarted via systemctl")
else:
# 回退:nohup 启动(显式关闭 debug 模式,避免 reloader 导致 APScheduler 不初始化)
restart_cmd = (
'cd /opt/troubleshoot/web && '
'pkill -f "python.*server.py" || true && '
'sleep 2 && '
'FLASK_DEBUG=0 nohup python3 server.py > /tmp/troubleshoot.log 2>&1 &'
)
stdin, stdout, stderr = ssh.exec_command(restart_cmd)
stdout.read()
print("[OK] Service restarted via nohup (FLASK_DEBUG=0)")
print("\nWaiting for service...") print("\nWaiting for service...")
time.sleep(3) time.sleep(3)
......
...@@ -10,6 +10,10 @@ requests==2.32.3 ...@@ -10,6 +10,10 @@ requests==2.32.3
# 同进程 service_monitor 子包依赖(PRD §4.6) # 同进程 service_monitor 子包依赖(PRD §4.6)
# cryptography 模块级导入,缺失则 create_app() 启动即崩,连累问题排查助手 # cryptography 模块级导入,缺失则 create_app() 启动即崩,连累问题排查助手
# paramiko 延迟导入(SSH 监测时),缺失则用到时崩 # paramiko 延迟导入(SSH 监测时),缺失则用到时崩
# apscheduler 定时任务调度,缺失则定时功能不可用
# croniter cron 表达式解析,缺失则定时任务无法创建和执行
# 版本以本地实测通过为准(service_monitor 51 用例 + 问题排查助手 167 用例全绿) # 版本以本地实测通过为准(service_monitor 51 用例 + 问题排查助手 167 用例全绿)
cryptography==46.0.7 cryptography==46.0.7
paramiko==4.0.0 paramiko==4.0.0
apscheduler==3.11.0
croniter==6.2.4
...@@ -11,8 +11,12 @@ werkzeug>=2.3.0 ...@@ -11,8 +11,12 @@ werkzeug>=2.3.0
# 服务监测模块依赖 # 服务监测模块依赖
# paramiko: SSH 远程巡检执行 # paramiko: SSH 远程巡检执行
# cryptography: SSH 凭据 Fernet 加密(paramiko 间接依赖,显式声明便于独立使用) # cryptography: SSH 凭据 Fernet 加密(paramiko 间接依赖,显式声明便于独立使用)
# apscheduler: 定时任务调度
# croniter: cron 表达式解析与下次执行时间计算
paramiko>=3.4.0 paramiko>=3.4.0
cryptography>=42.0.0 cryptography>=42.0.0
apscheduler>=3.10.0
croniter>=2.0.0
# 测试依赖(P1-2 单元测试) # 测试依赖(P1-2 单元测试)
pytest>=7.0.0 pytest>=7.0.0
......
...@@ -406,6 +406,14 @@ def health_check(): ...@@ -406,6 +406,14 @@ def health_check():
cache_stats = cache.get_stats() cache_stats = cache.get_stats()
search_mode_info = engine.get_search_mode() search_mode_info = engine.get_search_mode()
# 获取服务监测调度器状态(反映当前运行进程)
scheduler_status = {"initialized": False}
try:
from service_monitor.services import schedule_service
scheduler_status = schedule_service.get_scheduler_status()
except Exception as e:
scheduler_status = {"initialized": False, "error": str(e)}
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
...@@ -424,6 +432,7 @@ def health_check(): ...@@ -424,6 +432,7 @@ def health_check():
'total_files': cache_stats.get('total_files', 0), 'total_files': cache_stats.get('total_files', 0),
'total_size_mb': cache_stats.get('total_size_mb', 0), 'total_size_mb': cache_stats.get('total_size_mb', 0),
}, },
'scheduler': scheduler_status,
'components': { 'components': {
'search_engine': 'ok', 'search_engine': 'ok',
'safety_filter': 'ok', 'safety_filter': 'ok',
......
...@@ -146,8 +146,9 @@ if __name__ == '__main__': ...@@ -146,8 +146,9 @@ if __name__ == '__main__':
print(f"🌐 服务地址:http://localhost:8088") print(f"🌐 服务地址:http://localhost:8088")
print("="*50) print("="*50)
# debug 从环境变量读取(默认开发环境开启,生产用 FLASK_DEBUG=0 关闭) # debug 从环境变量读取(默认关闭,生产安全;开发用 FLASK_DEBUG=1 开启)
debug = os.environ.get('FLASK_DEBUG', '1') == '1' # debug 模式的 reloader 会导致 APScheduler 在父进程初始化后丢失,定时任务失效
debug = os.environ.get('FLASK_DEBUG', '0') == '1'
app.run( app.run(
host='0.0.0.0', host='0.0.0.0',
......
...@@ -34,10 +34,11 @@ ...@@ -34,10 +34,11 @@
border-radius: 12px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,.06); box-shadow: 0 2px 8px rgba(0,0,0,.06);
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
overflow: hidden;
} }
.report-row { .report-row {
display: grid; display: grid;
grid-template-columns: 2fr 1fr 2fr 2fr 1fr; grid-template-columns: 40px 2fr 1fr 2fr 2fr 1fr;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
padding: 14px 18px; padding: 14px 18px;
...@@ -46,6 +47,18 @@ ...@@ -46,6 +47,18 @@
} }
.report-row:last-child { border-bottom: none; } .report-row:last-child { border-bottom: none; }
.report-row:hover { background: var(--gray-50); } .report-row:hover { background: var(--gray-50); }
.report-row.is-link { cursor: pointer; }
.report-checkbox {
display: flex;
align-items: center;
justify-content: center;
}
.report-checkbox input {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary);
}
.report-target { font-weight: 600; color: var(--gray-900); } .report-target { font-weight: 600; color: var(--gray-900); }
.report-suite { font-size: 13px; color: var(--gray-500); } .report-suite { font-size: 13px; color: var(--gray-500); }
.report-summary { display: flex; gap: 10px; font-size: 13px; } .report-summary { display: flex; gap: 10px; font-size: 13px; }
...@@ -63,9 +76,36 @@ ...@@ -63,9 +76,36 @@
} }
.btn-view:hover { background: var(--gray-200); } .btn-view:hover { background: var(--gray-200); }
/* 批量操作栏 */
.batch-bar {
display: none;
align-items: center;
gap: 16px;
padding: 12px 18px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
border: 1px solid var(--gray-200);
margin-bottom: 12px;
}
.batch-bar.show { display: flex; }
.batch-count { font-size: 14px; color: var(--gray-700); font-weight: 600; }
.btn-batch-del {
background: #fee2e2;
color: var(--red);
border: none;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
margin-left: auto;
}
.btn-batch-del:hover { background: #fecaca; }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.report-row { grid-template-columns: 1fr 1fr; gap: 6px; } .report-row { grid-template-columns: 32px 1fr 1fr; gap: 6px; }
.report-summary { font-size: 12px; } .report-summary, .report-time { display: none; }
.filter-bar { flex-wrap: wrap; } .filter-bar { flex-wrap: wrap; }
} }
</style> </style>
...@@ -86,11 +126,25 @@ ...@@ -86,11 +126,25 @@
<span class="report-count">共 {{ reports | length }} 条报告</span> <span class="report-count">共 {{ reports | length }} 条报告</span>
</div> </div>
{% if is_admin and reports %}
<!-- 批量操作栏 -->
<div class="batch-bar" id="batch-bar">
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" style="width:16px;height:16px;accent-color:var(--primary);">
<span class="batch-count" id="batch-count">已选 0 条</span>
<button class="btn-batch-del" onclick="batchDelete()">🗑 批量删除</button>
</div>
{% endif %}
{% if reports %} {% if reports %}
<div class="report-list"> <div class="report-list">
{% for r in reports %} {% for r in reports %}
<a href="/service-monitor/report/{{ r.id }}" class="report-row"> <div class="report-row">
<span class="report-target">{{ r.target_name }}</span> {% if is_admin %}
<div class="report-checkbox">
<input type="checkbox" class="report-check" value="{{ r.id }}" onchange="updateBatchCount()">
</div>
{% endif %}
<a href="/service-monitor/report/{{ r.id }}" class="report-target is-link" style="text-decoration:none;color:inherit;">{{ r.target_name }}</a>
<span class="report-suite">{{ '快速' if r.suite == 'quick' else '全量' }}</span> <span class="report-suite">{{ '快速' if r.suite == 'quick' else '全量' }}</span>
<span class="report-summary"> <span class="report-summary">
<span class="sm-ok">✓{{ r.summary.正常 if r.summary else 0 }}</span> <span class="sm-ok">✓{{ r.summary.正常 if r.summary else 0 }}</span>
...@@ -98,8 +152,8 @@ ...@@ -98,8 +152,8 @@
<span class="sm-crit">✗{{ r.summary.严重 if r.summary else 0 }}</span> <span class="sm-crit">✗{{ r.summary.严重 if r.summary else 0 }}</span>
</span> </span>
<span class="report-time">{{ (r.started_at or '')[5:16].replace('T', ' ') }}</span> <span class="report-time">{{ (r.started_at or '')[5:16].replace('T', ' ') }}</span>
<span class="btn-view">查看</span> <a href="/service-monitor/report/{{ r.id }}" class="btn-view">查看</a>
</a> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
...@@ -116,5 +170,63 @@ ...@@ -116,5 +170,63 @@
: '/service-monitor/reports'; : '/service-monitor/reports';
window.location.href = url; window.location.href = url;
} }
function toggleSelectAll(el) {
document.querySelectorAll('.report-check').forEach(cb => cb.checked = el.checked);
updateBatchCount();
}
function updateBatchCount() {
const checks = document.querySelectorAll('.report-check:checked');
const count = checks.length;
document.getElementById('batch-count').textContent = `已选 ${count} 条`;
// 全选框状态同步
const all = document.querySelectorAll('.report-check');
document.getElementById('select-all').checked = all.length > 0 && count === all.length;
}
// 任意 checkbox 变化时显示批量操作栏
document.addEventListener('change', function(e) {
if (e.target.classList.contains('report-check')) {
const bar = document.getElementById('batch-bar');
const hasChecked = document.querySelectorAll('.report-check:checked').length > 0;
bar.classList.toggle('show', true); // 管理员始终显示批量栏
}
});
// 初始化:管理员且有报告时显示批量栏
(function() {
const bar = document.getElementById('batch-bar');
if (bar && {{ 'true' if is_admin and reports else 'false' }}) {
bar.classList.add('show');
}
})();
async function batchDelete() {
const ids = Array.from(document.querySelectorAll('.report-check:checked')).map(cb => cb.value);
if (ids.length === 0) {
alert('请先选择要删除的报告');
return;
}
if (!confirm(`确定删除选中的 ${ids.length} 份报告?此操作不可恢复。`)) return;
try {
const r = await fetch('/api/service-monitor/reports/batch', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ids: ids }),
});
const d = await r.json();
if (d.success) {
alert(`成功删除 ${d.deleted} 份报告`);
location.reload();
} else {
alert(d.error?.message || '删除失败');
}
} catch (e) {
alert('请求失败');
}
}
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论