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

feat(service-monitor): 定时任务执行状态展示 + 立即执行按钮

- schedule.html: 表格新增「上次执行」列(时间+成功/失败/运行中徽章+报告链接)
- schedule.html: 操作列新增「执行」按钮,点击后后台线程执行、3秒轮询自动刷新
- schedule_service.py: 抽 _run_job_body 共享执行体、新增 run_now() 手动触发
- schedule_service.py: create_schedule 初始化 current_status:idle
- schedule_service.py: 异常路径 last_run_status 与 current_status 统一为 error
- routes.py: page_schedule 注入 run_badge/run_label;新增 POST .../schedules/<id>/run
- 响应式适配:平板端隐藏套件/上次执行/下次执行列
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 633da268
# HANDOFF — 定时任务弹窗优化 + 通知配置 + 定时任务不执行修复 # HANDOFF — 定时任务执行状态展示 + 立即执行功能
> 最后更新:2026-07-21 15:07 | 分支:troubleshoot-ai-assistant | 负责人:czj > 最后更新:2026-07-21 16:30 | 分支:troubleshoot-ai-assistant | 负责人:czj
--- ---
## 1. 我们在做什么 ## 1. 我们在做什么
次会话承接上一轮的"左侧菜单改造 + 定时自动巡检"功能,继续推进服务监测模块的完善,共完成三项任务: 轮解决用户反馈的"定时任务还是没执行"问题。经排查 `/api/health` 确认 `scheduler.running=true, job_count=3` —— **APScheduler 已启动、任务已注册**,问题不在"调度器没启动",而在"到点没触发(疑似时区)或触发后执行失败"。
**任务一:定时任务弹窗交互优化**。原弹窗使用 Cron 表达式(如 `0 8 * * *`)配置调度周期,非技术人员无法理解。改为"每天/工作日/每周"三种重复周期 + 时间选择器 + 星期多选 + 起止日期的用户友好界面。来源 PRD:`Docs/需求文档/服务监测/PRD_需求文档_定时任务弹窗优化.md` 但页面**完全看不到执行情况**,用户无从判断。本次新增:
**任务二:通知配置功能**。定时巡检完成后用户无法及时获知结果,新增"通知配置"菜单页,支持邮件(SMTP)、钉钉机器人、企业微信机器人三种通知渠道,可选择触发条件和测试发送。来源 PRD:`Docs/需求文档/服务监测/PRD_需求文档_通知配置.md` 1. **「上次执行」列**:展示时间 + 成功/失败/运行中徽章 + 报告链接
2. **「立即执行」按钮**:手动触发一次,后台线程执行、立即返回、状态自动刷新
3. **异常路径状态统一**`last_run_status``current_status` 保持一致
**任务三:定时任务不执行问题修复**。用户反馈设置了定时任务但到了时间不执行、不输出报告。排查发现三个根因:①Flask debug 模式的 reloader 机制导致 APScheduler 在错误进程初始化;②start_date/end_date 传字符串而非 datetime 对象给 APScheduler;③croniter 依赖未在 requirements.txt 声明。来源:`Docs/需求文档/服务监测/PRD_问题处理_定时任务与报告优化.md` 这套组合可定位"不执行"根因:点「立即执行」成功 → 执行链路没问题,问题在 cron 到点(对比"下次执行"时间,若晚 8 小时即确认时区问题);失败 → 徽章直接暴露错误。
> 历史背景:上一轮已完成"定时任务弹窗优化(commit 28f55365)+ 通知配置(fdb0ee63)+ 定时任务不执行三根因修复(9d887980)"。本轮在其基础上补执行可见性。
--- ---
## 2. 已经完成了什么 ## 2. 已经完成了什么
### ✅ 定时任务弹窗交互优化 ### ✅ 定时任务执行状态展示 + 立即执行(本轮)
- 删除 Cron 表达式输入框和预设按钮,改为**重复周期单选组**(每天/工作日/每周) **页面新增「上次执行」列**
- 新增**时:分下拉选择器**(0-23 时,每5分钟) - 表格从 7 列扩展为 8 列:`任务名称 | 目标 | 套件 | 调度周期 | 启用 | 上次执行 | 下次执行 | 操作`
- 新增**星期多选按钮组**(仅每周模式显示,至少选1个) - 显示时间(`last_run_at`)、执行状态徽章(`current_status` 优先)、报告链接(`last_report_id`
- 新增**生效日期/失效日期**选择器 - 徽章样式:🔄 执行中(蓝)、✅ 成功(绿)、❌ 失败/⚠️ 异常(红)、⏸ 待执行(灰)
- 列表"Cron"列改为**"调度周期"**,显示自然语言描述(如"工作日 09:30"、"每周一、周五 10:00") - 响应式:平板端隐藏"套件""上次执行""下次执行"
- 后端 `schedule_service.py` 新增 `_build_cron()` / `_describe_schedule()` 函数,自动转换和描述
- 数据模型新增字段:`repeat_mode` / `hour` / `minute` / `weekdays` / `start_date` / `end_date`
- 部署到 5.60 验证通过:创建"工作日巡检"显示"工作日 09:30",编辑回填正确
### ✅ 通知配置功能 **新增「立即执行」按钮**
- 操作列新增「执行」按钮(蓝色),点击后 POST `/api/service-monitor/schedules/<id>/run`
- 后台线程执行巡检(`run_now()` 函数),HTTP 立即返回,不阻塞
- 前端立即显示"执行中"徽章,启动 3 秒轮询直到完成
- 禁用状态下也可执行(手动触发是显式动作)
- 左侧菜单新增 🔔 通知配置(在"定时任务"和"目标管理"之间,仅管理员可见) **后端改动**
- 新增 `notification_service.py`:支持邮件/钉钉/企业微信三种渠道的配置 CRUD、测试发送、巡检完成后通知 - `schedule_service.py`:抽 `_run_job_body()` 共享执行体、新增 `run_now()``create_schedule()``"current_status": "idle"`、异常路径状态统一为 `error`
- 新增 `notification.html`:三个渠道配置卡片(启用开关 + 表单 + 测试按钮)+ 触发条件配置 - `routes.py``page_schedule` 注入 `run_badge`/`run_label`;新增 `POST .../schedules/<id>/run` 路由
- `runner_service.py``run_inspection_sync()` 完成后调用通知服务
- 敏感字段(SMTP 密码、钉钉密钥)用 Fernet 加密存储(复用 `crypto.py``encrypt_password`
- 部署到 5.60 验证通过:页面功能正常,邮件配置展开后表单完整
### ✅ 定时任务不执行修复 **验证**
- 本地测试:167 用例 + service_monitor 51 用例全绿 ✅
- 新路由已注册 ✅
- 待 5.60 部署后验证实际执行效果
**根因 1:Flask debug reloader**(核心问题) ---
- 修复:`server.py``FLASK_DEBUG` 默认值从 `'1'` 改为 `'0'`
- 修复:`schedule_service.init_scheduler()` 增加 `WERKZEUG_RUN_MAIN` 环境变量检查,仅在 reloader 子进程初始化
- 修复:`deploy/upload_to_server.py` 启动命令显式设置 `FLASK_DEBUG=0`
**根因 2:start_date/end_date 类型错误** ### ✅ 定时任务弹窗交互优化(commit 28f55365)
- 修复:`_add_job()` 中将字符串转为 `datetime` 对象并附加 `timezone.utc`
**根因 3:croniter 依赖缺失** - 删除 Cron 输入框,改为**重复周期单选组**(每天/工作日/每周)
- 修复:`requirements.txt` 增加 `apscheduler==3.11.0` + `croniter==6.2.4` - 新增**时:分下拉选择器**(0-23 时,每5分钟)
- 修复:`skill/code/requirements.txt` 增加 `apscheduler>=3.10.0` + `croniter>=2.0.0` - 新增**星期多选按钮组**(仅每周模式,至少选1个)
- 修复:`deploy/upload_to_server.py``_REQUIRED_PACKAGES` 增加 `'croniter'` - 新增**生效日期/失效日期**选择器
- 列表"Cron"列改为**"调度周期"**,显示自然语言描述(如"工作日 09:30")
- 后端新增 `_build_cron()` / `_describe_schedule()` 自动转换
- 数据模型新增 `repeat_mode`/`hour`/`minute`/`weekdays`/`start_date`/`end_date`
- **验证**:5.60 浏览器验证通过,创建任务正确显示,编辑回填正确
**验证**`/api/health` 返回 `scheduler: {'running': true, 'job_count': 3}`,3 个定时任务全部注册 ### ✅ 通知配置功能(commit fdb0ee63)
### ✅ 编辑保存提示 - 左侧菜单新增 🔔 通知配置(仅管理员可见,位于定时任务与目标管理之间)
- 新增 `notification_service.py`:邮件/钉钉/企业微信配置 CRUD、测试发送、巡检后通知
- 新增 `notification.html`:三渠道配置卡片 + 触发条件 + 测试按钮
- `runner_service.py``run_inspection_sync()` 完成后调用通知服务
- 敏感字段用 Fernet 加密存储(复用 `crypto.py``encrypt_password`
- **验证**:5.60 浏览器验证页面功能正常
- `schedule.html``saveSchedule()` 成功后增加 `alert('保存成功')` / `alert('创建成功')` ### ✅ 定时任务不执行修复(commit 9d90d646)
### ✅ 执行状态显示 **根因 1:Flask debug reloader**(核心)
- `server.py``FLASK_DEBUG` 默认值从 `'1'` 改为 `'0'`
- `init_scheduler()` 增加 `WERKZEUG_RUN_MAIN` 环境变量检查
- `upload_to_server.py` 启动命令显式 `FLASK_DEBUG=0`
- `schedule_service.py` 新增 `_update_current_status()` 函数 **根因 2:start_date 类型错误**
- `_execute_scheduled_job()` 开始时标记 `running`,成功标记 `success`,失败标记 `failed` - `_add_job()` 中字符串转 `datetime` 并附加 `timezone.utc`,end_date 设为 `23:59:59`
- `schedules.json` 增加 `current_status` 字段
### ✅ 批量删除报告 **根因 3:croniter 依赖缺失**
- `requirements.txt` 增加 `apscheduler==3.11.0` + `croniter==6.2.4`
- `skill/code/requirements.txt` 增加对应依赖
- `upload_to_server.py``_REQUIRED_PACKAGES` 增加 `'croniter'`
**验证**`/api/health` 返回 `scheduler: {'running': true, 'job_count': 3}`
- `reports.html` 增加 checkbox、全选、批量删除按钮 ### ✅ 其他改进
- `routes.py` 新增 `DELETE /api/service-monitor/reports/batch` 接口
### ✅ health 接口增强 - **编辑保存提示**`schedule.html` 成功后 `alert('保存成功')`
- **执行状态显示**`schedule_service.py` 新增 `_update_current_status()``current_status` 字段(idle/running/success/failed)
- **批量删除报告**`reports.html` checkbox + 全选 + 批量删除按钮;`routes.py` 新增 `DELETE /api/service-monitor/reports/batch`
- **health 接口增强**`routes/troubleshoot.py` 增加 `scheduler` 状态字段
- `routes/troubleshoot.py``/api/health` 增加 `scheduler` 状态字段,暴露 APScheduler 运行状态 ### ✅ 提交并推送(commit 9d887980 / 633da268)
5 个提交已推送到 `origin troubleshoot-ai-assistant`
```
28f55365 feat(service-monitor): 定时任务弹窗交互优化
fdb0ee63 feat(service-monitor): 通知配置功能(邮件/钉钉/企业微信)
9d90d646 fix(service-monitor): 定时任务不执行修复 + 依赖补充 + 报告批量删除
9d887980 docs: 更新 HANDOFF 交接文档
633da268 docs(service-monitor): 定时任务弹窗优化 PRD 文档
```
--- ---
## 3. 当前卡在哪 ## 3. 当前卡在哪
**无卡点**所有功能已实现并部署到 5.60。 **无卡点**代码改动完成、测试通过、待部署到 5.60。
但有一个**待验证项**:定时任务到时间后是否真正执行并生成报告。APScheduler 已正确初始化(health 接口确认),但尚未等一个完整周期验证报告输出。建议创建一个 2 分钟后触发的定时任务来快速验证。 **待验证项**
1. **本地验证**:起服务 → 定时任务页点「执行」→ 徽章刷新 → 报告链接跳转
2. **5.60 部署验证**:点「立即执行」若成功 → 对比"下次执行"时间判断是否时区问题
--- ---
## 4. 下一步计划 ## 4. 下一步计划
1. **验证定时任务实际执行**(P0) 1. **部署到 5.60**(P0)
- 在页面上创建一个 2 分钟后触发的定时任务 - 执行 `! cd deploy && SSH_PASSWORD='***' python upload_to_server.py`
- 等待执行后检查巡检报告是否生成 - 刷新页面确认「上次执行」列显示、「执行」按钮可见
- 检查 `schedules.json``last_run_at``last_report_id` 是否更新
2. **提交代码**(P0)
- 当前有 13 个修改文件 + 7 个新增文件未提交
- 建议拆分为 3-4 个提交:
- `feat(service-monitor): 定时任务弹窗交互优化(友好调度配置替代 Cron 表达式)`
- `feat(service-monitor): 通知配置功能(邮件/钉钉/企业微信)`
- `fix(service-monitor): 定时任务不执行修复 + 依赖补充 + 报告批量删除`
- 所有 `Docs/需求文档/` 下的新增 PRD 文件也需要提交
3. **清理 5.60 上的测试定时任务**(P1 2. **验证立即执行功能**(P0
- 删除之前创建的测试任务(如不需要) - 点「执行」→ 徽章变"执行中"→ 数秒后自动刷新为"成功/失败"
- 确保 schedules.json 数据干净 - 点「报告」链接跳转正常
4. **配置实际通知渠道**(P2 3. **定位不执行根因**(P1
- 配置邮件/钉钉/企业微信的真实凭据 - 若「立即执行」成功 → 问题在 cron 到点;对比"下次执行"时间,晚 8 小时即确认时区问题(需后续修 CronTrigger 时区)
- 发送测试验证 - 若「立即执行」失败 → 徽章直接暴露错误,看日志定位
5. **systemd 服务文件补充 FLASK_DEBUG=0**(P2) 4. **创建 Merge Request**(P2)
- 当前服务文件缺少此环境变量,依赖 server.py 的默认值 - 访问 http://git.ubainsyun.com/bing/ubains-module-test/merge_requests/new?merge_request%5Bsource_branch%5D=troubleshoot-ai-assistant
- 建议手动在 `/etc/systemd/system/troubleshoot.service``[Service]` 段加 `Environment=FLASK_DEBUG=0` - 将 troubleshoot-ai-assistant 合并到 master
--- ---
## 5. 踩过的坑(绝对不要再踩) ## 5. 踩过的坑(绝对不要再踩)
### 坑 0:定时任务"看起来没执行",实际是页面没展示执行状态(本轮)
**坑**:用户反馈"还是没执行",但 `/api/health` 显示 `scheduler.running=true, job_count:3` —— 调度器在跑、任务已注册。真正问题是 `schedule.html` 的"状态"列只显示启用/禁用开关,`last_run_at`/`last_run_status`/`current_status`/`last_report_id` 这些字段数据都有但 UI 没渲染,用户无从判断。
**避免方法**
- 定时任务功能必须有**执行可见性**:上次执行时间 + 成功/失败徽章 + 报告链接
- 加「立即执行」按钮可主动验证执行链路,区分"到点没触发"和"触发后失败"
### 坑 0.5:疑似时区问题(待验证)
**坑**`_add_job()``CronTrigger.from_crontab(cron)`,默认按服务器本地时区解析 cron 字段。若 5.60 系统时区是 UTC,用户配"每天 09:30"实际会在北京时间 17:30 才触发,看起来像"没执行"。
**避免方法**:部署后对比"下次执行"时间与预期,晚 8 小时即确认。后续可显式锁定 `CronTrigger` 时区为 `Asia/Shanghai`
### 坑 1:Flask debug 模式的 reloader 导致 APScheduler 失效(最严重) ### 坑 1:Flask debug 模式的 reloader 导致 APScheduler 失效(最严重)
**坑**`server.py` 默认 `FLASK_DEBUG='1'`,Flask debug 模式使用 Werkzeug reloader,fork 出子进程后,父进程中初始化的 APScheduler 被丢弃,子进程中 `_scheduler` 全局变量重置为 None。定时任务注册在即将被丢弃的父进程中,实际处理请求的子进程没有调度器 **坑**`server.py` 默认 `FLASK_DEBUG='1'`,Flask debug 模式用 Werkzeug reloader fork 出子进程后,父进程中初始化的 APScheduler 被丢弃,子进程 `_scheduler` 全局变量重置为 None。症状是定时任务"看起来注册成功但永远不执行"
**为什么会踩**:开发环境默认开启 debug 方便热重载,但 reloader 的双进程机制与 APScheduler 的单进程初始化冲突。症状是定时任务"看起来注册成功但永远不执行" **为什么踩**:开发环境默认开 debug 方便热重载,但 reloader 双进程机制与 APScheduler 单进程初始化冲突
**避免方法** **避免方法**
- 生产环境**必须**设置 `FLASK_DEBUG=0` - 生产环境**必须** `FLASK_DEBUG=0`
- `init_scheduler()` 要检查 `WERKZEUG_RUN_MAIN` 环境变量,仅在子进程中初始化 - `init_scheduler()` 检查 `WERKZEUG_RUN_MAIN` 环境变量,仅在子进程初始化
- health 接口暴露 scheduler 状态,方便快速诊断 - health 接口暴露 scheduler 状态快速诊断
### 坑 2:APScheduler 的 start_date/end_date 需要带时区的 datetime ### 坑 2:APScheduler 的 start_date/end_date 需要带时区的 datetime
**坑**:直接把 `"2026-07-20"` 字符串赋值给 `CronTrigger.start_date`,报错 `'str' object has no attribute 'astimezone'`。改为 `datetime` 对象后又报错 `can't compare offset-naive and offset-aware datetimes` **坑**:直接把 `"2026-07-20"` 字符串赋`CronTrigger.start_date``'str' object has no attribute 'astimezone'`。改为 datetime 后又报 `can't compare offset-naive and offset-aware datetimes`
**避免方法**:用 `datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)` 转换为 UTC 时区的 datetime 对象。end_date 当天也要执行,需设为 `23:59:59` **避免方法**:用 `datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)` 转换。end_date 当天也要执行,设为 `23:59:59`
### 坑 3:croniter 依赖未声明 ### 坑 3:croniter 依赖未声明
**坑**`apscheduler``upload_to_server.py``_REQUIRED_PACKAGES` 中声明了,`croniter` 没有。`croniter``schedule_service.py` 的间接依赖(`_calc_next_run()``create_schedule()` 中使用),缺失导致 `next_run_at` 为 null **坑**`apscheduler``upload_to_server.py``_REQUIRED_PACKAGES` 中声明了,`croniter` 没有。缺失导致 `next_run_at` 为 null,任务无法注册
**避免方法****所有 Python 依赖必须在三个位置同步声明** **避免方法****所有 Python 依赖必须在三同步声明**
1. `requirements.txt`(容器化部署) 1. `requirements.txt`(容器化部署)
2. `skill/code/requirements.txt`(开发环境) 2. `skill/code/requirements.txt`(开发环境)
3. `deploy/upload_to_server.py``_REQUIRED_PACKAGES`(SSH 部署) 3. `deploy/upload_to_server.py``_REQUIRED_PACKAGES`(SSH 部署)
### 坑 4:notification_service 导入了不存在的函数名 ### 坑 4:notification_service 导入了不存在的函数名
**坑**:导入了 `encrypt_value` / `decrypt_value`,但 `crypto.py` 中的实际函数名是 `encrypt_password` / `decrypt_password` **坑**:导入了 `encrypt_value`/`decrypt_value`,但 `crypto.py` 实际函数名是 `encrypt_password`/`decrypt_password`,导致 `ImportError` 服务启动崩溃
**避免方法**:新增模块前检查依赖模块的实际函数签名,不要凭记忆写函数名。 **避免方法**:新增模块前检查依赖模块的实际函数签名,不要凭记忆写函数名。
### 坑 5:5.60 服务器 pip install 需要特殊参数 ### 坑 5:5.60 服务器 pip install 需要特殊参数
**坑**:5.60 服务器是 Ubuntu + Python 3.14,`pip install``externally-managed-environment` 错误,需要加 `--break-system-packages` 参数。 **坑**:5.60 是 Ubuntu + Python 3.14,`pip install``externally-managed-environment`,需加 `--break-system-packages`
**避免方法**`upload_to_server.py` 的依赖安装命令已加此参数;后续手动安装时也要加。
### 坑 6:APScheduler 验证要用 HTTP 接口而非新进程
**坑**:用 `python3 -c "from schedule_service import ..."` 验证 scheduler 状态时,这是**全新独立进程**`_scheduler` 当然是 None,不能反映运行中服务进程的真实状态。
**避免方法**`upload_to_server.py` 的依赖安装命令已加此参数,后续手动安装时也要加 **避免方法**通过 HTTP 接口(health 加 scheduler 字段)检查运行中进程的 scheduler 状态,不要新开 Python 进程验证
--- ---
...@@ -172,31 +213,39 @@ ...@@ -172,31 +213,39 @@
### 本次修改文件 ### 本次修改文件
| 文件 | 改动要点 |
|------|---------|
| `skill/code/web/service_monitor/services/schedule_service.py` | 抽 `_run_job_body`、新增 `run_now``create_schedule``current_status:idle`、异常状态统一为 `error` |
| `skill/code/web/service_monitor/routes.py` | `page_schedule` 注入 `run_badge`/`run_label`;新增 `POST .../schedules/<id>/run` |
| `skill/code/web/templates/service_monitor/schedule.html` | 表格 7→8 列、执行状态徽章 CSS、执行按钮、3 秒轮询自动刷新、响应式适配 |
### 历史修改文件(前几轮)
| 文件 | 改动要点 | | 文件 | 改动要点 |
|------|---------| |------|---------|
| `skill/code/web/server.py` | FLASK_DEBUG 默认值改为 `'0'` | | `skill/code/web/server.py` | FLASK_DEBUG 默认值改为 `'0'` |
| `skill/code/web/service_monitor/services/schedule_service.py` | 新增 _build_cron/_describe_schedule/_update_current_status;init_scheduler 增加 reloader 防护;_add_job 修复 start_date 类型 | | `skill/code/web/service_monitor/services/schedule_service.py` | _build_cron/_describe_schedule/_update_current_status + reloader 防护 + start_date 类型修复 |
| `skill/code/web/service_monitor/services/runner_service.py` | run_inspection_sync 完成后调用通知服务 | | `skill/code/web/service_monitor/services/runner_service.py` | run_inspection_sync 完成后调用通知 |
| `skill/code/web/service_monitor/utils/paths.py` | 增加 NOTIFICATIONS_FILE | | `skill/code/web/service_monitor/utils/paths.py` | 增加 NOTIFICATIONS_FILE |
| `skill/code/web/service_monitor/routes.py` | 新增通知配置 6 个路由 + 批量删除 API | | `skill/code/web/service_monitor/routes.py` | 通知配置 6 个路由 + 批量删除 API |
| `skill/code/web/routes/troubleshoot.py` | health 接口增加 scheduler 状态 | | `skill/code/web/routes/troubleshoot.py` | health 接口增加 scheduler 状态 |
| `skill/code/web/templates/service_monitor/base.html` | 左侧菜单增加"通知配置" | | `skill/code/web/templates/service_monitor/base.html` | 左侧菜单增加"通知配置" |
| `skill/code/web/templates/service_monitor/schedule.html` | 弹窗全面重写(友好调度)+ 保存提示 | | `skill/code/web/templates/service_monitor/schedule.html` | 弹窗全面重写 + 保存提示 |
| `skill/code/web/templates/service_monitor/reports.html` | 增加 checkbox + 批量删除 | | `skill/code/web/templates/service_monitor/reports.html` | checkbox + 批量删除 |
| `requirements.txt` | 增加 apscheduler==3.11.0 + croniter==6.2.4 | | `requirements.txt` | +apscheduler==3.11.0 +croniter==6.2.4 |
| `skill/code/requirements.txt` | 增加 apscheduler>=3.10.0 + croniter>=2.0.0 | | `skill/code/requirements.txt` | +apscheduler>=3.10.0 +croniter>=2.0.0 |
| `deploy/upload_to_server.py` | _REQUIRED_PACKAGES 增加 croniter;启动命令加 FLASK_DEBUG=0 | | `deploy/upload_to_server.py` | _REQUIRED_PACKAGES +croniter;启动命令 +FLASK_DEBUG=0 |
### 常用命令 ### 常用命令
```bash ```bash
# 本地启动(开发环境,debug 模式) # 本地启动(开发 debug 模式)
FLASK_DEBUG=1 python skill/code/web/server.py FLASK_DEBUG=1 python skill/code/web/server.py
# 本地启动(生产模式,定时任务可用) # 本地启动(生产模式,定时任务可用)
python skill/code/web/server.py python skill/code/web/server.py
# 部署到 5.60(用 ! 前缀在会话内执行) # 部署到 5.60(用 ! 前缀在会话内执行,避免读不到 SSH_PASSWORD
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py ! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
# 健康检查(含 scheduler 状态) # 健康检查(含 scheduler 状态)
...@@ -206,13 +255,6 @@ curl -s http://192.168.5.60:8088/api/health ...@@ -206,13 +255,6 @@ curl -s http://192.168.5.60:8088/api/health
cd skill/code && python -m pytest -v cd skill/code && python -m pytest -v
``` ```
### 服务器凭据
| 账号 | 用户名 | 密码 |
|------|--------|------|
| SSH | ubains | 见环境变量 SSH_PASSWORD |
| Web 管理员 | admin | 见 .env |
### 数据文件 ### 数据文件
| 数据 | 文件 | | 数据 | 文件 |
...@@ -221,3 +263,7 @@ cd skill/code && python -m pytest -v ...@@ -221,3 +263,7 @@ cd skill/code && python -m pytest -v
| 巡检报告 | `service_monitor/data/reports/*.json` | | 巡检报告 | `service_monitor/data/reports/*.json` |
| 定时任务 | `service_monitor/data/schedules.json` | | 定时任务 | `service_monitor/data/schedules.json` |
| 通知配置 | `service_monitor/data/notifications.json` | | 通知配置 | `service_monitor/data/notifications.json` |
### 未提交文件
- `skill/code/web/users.json`(仅登录统计更新,无需提交)
...@@ -113,9 +113,21 @@ def page_schedule(): ...@@ -113,9 +113,21 @@ def page_schedule():
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
user = _current_user() user = _current_user()
schedules = schedule_service.list_schedules() schedules = schedule_service.list_schedules()
# 为每个 schedule 生成自然语言描述 # 为每个 schedule 生成自然语言描述 + 执行状态徽章
_STATUS_MAP = {
"running": ("badge-running", "🔄 执行中"),
"success": ("badge-success", "✅ 成功"),
"failed": ("badge-failed", "❌ 失败"),
"error": ("badge-error", "⚠️ 异常"),
"idle": ("badge-idle", "⏸ 待执行"),
}
for s in schedules: for s in schedules:
s['description'] = schedule_service._describe_schedule(s) s['description'] = schedule_service._describe_schedule(s)
# current_status 优先,其次 last_run_status,默认 idle
status_key = s.get("current_status") or s.get("last_run_status") or "idle"
badge_cls, label = _STATUS_MAP.get(status_key, ("badge-idle", "⏸ 待执行"))
s['run_badge'] = badge_cls
s['run_label'] = label
targets = target_service.list_targets(role=user.get('role', '')) targets = target_service.list_targets(role=user.get('role', ''))
return render_template( return render_template(
'service_monitor/schedule.html', 'service_monitor/schedule.html',
...@@ -437,6 +449,19 @@ def api_toggle_schedule(schedule_id): ...@@ -437,6 +449,19 @@ def api_toggle_schedule(schedule_id):
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400 return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
@bp.route('/api/service-monitor/schedules/<schedule_id>/run', methods=['POST'])
def api_run_schedule(schedule_id):
"""手动立即执行一次定时任务(后台线程,立即返回)。"""
guard = _require_admin_json()
if guard:
return guard
try:
result = schedule_service.run_now(schedule_id)
return jsonify({"success": True, **result})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
# ============================================================ # ============================================================
# API:通知配置(管理员) # API:通知配置(管理员)
# ============================================================ # ============================================================
......
...@@ -239,6 +239,7 @@ def create_schedule(data: dict, created_by: str) -> dict: ...@@ -239,6 +239,7 @@ def create_schedule(data: dict, created_by: str) -> dict:
"start_date": start_date, "start_date": start_date,
"end_date": end_date, "end_date": end_date,
"enabled": True, "enabled": True,
"current_status": "idle", # 初始化执行状态
"last_run_at": None, "last_run_at": None,
"last_run_status": None, "last_run_status": None,
"last_report_id": None, "last_report_id": None,
...@@ -458,13 +459,13 @@ def _remove_job(schedule_id: str) -> None: ...@@ -458,13 +459,13 @@ def _remove_job(schedule_id: str) -> None:
pass # job 可能不存在 pass # job 可能不存在
def _execute_scheduled_job(schedule_id: str) -> None: def _run_job_body(schedule_id: str) -> None:
"""定时任务执行入口(在 APScheduler 线程中调用)。""" """定时任务执行体(不含 enabled 检查,供 APScheduler 和 run_now 共用)。"""
logger.info("定时任务触发: %s", schedule_id) logger.info("定时任务执行开始: %s", schedule_id)
sched = get_schedule(schedule_id) sched = get_schedule(schedule_id)
if not sched or not sched.get("enabled"): if not sched:
logger.warning("定时任务不存在或已禁用: %s", schedule_id) logger.warning("定时任务不存在: %s", schedule_id)
return return
# 标记执行中 # 标记执行中
...@@ -491,7 +492,40 @@ def _execute_scheduled_job(schedule_id: str) -> None: ...@@ -491,7 +492,40 @@ def _execute_scheduled_job(schedule_id: str) -> None:
report_id="", report_id="",
run_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), run_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
) )
_update_current_status(schedule_id, "failed") _update_current_status(schedule_id, "error") # 与 last_run_status 一致
def _execute_scheduled_job(schedule_id: str) -> None:
"""定时任务执行入口(APScheduler 线程中调用),含 enabled 检查。"""
logger.info("定时任务触发: %s", schedule_id)
sched = get_schedule(schedule_id)
if not sched or not sched.get("enabled"):
logger.warning("定时任务不存在或已禁用: %s", schedule_id)
return
_run_job_body(schedule_id)
def run_now(schedule_id: str) -> dict:
"""手动立即执行一次定时任务(后台线程,立即返回,不阻塞 HTTP 请求)。
巡检可能耗时数分钟(单模块 90s 超时),故用后台线程执行。
不检查 enabled —— 手动执行是显式动作,禁用状态也应能跑。
页面通过 current_status 轮询感知 running → 完成。
"""
sched = get_schedule(schedule_id)
if not sched:
raise ValueError("定时任务不存在")
# 避免重复触发:已在执行中则跳过
if sched.get("current_status") == "running":
raise ValueError("该任务正在执行中,请稍后刷新查看")
t = threading.Thread(target=_run_job_body, args=(schedule_id,), daemon=True)
t.start()
logger.info("手动触发定时任务: %s", schedule_id)
return {"schedule_id": schedule_id, "triggered": True}
def reload_job(schedule_id: str) -> None: def reload_job(schedule_id: str) -> None:
......
...@@ -3,20 +3,37 @@ ...@@ -3,20 +3,37 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
/* 表格 */ /* 表格(8 列:新增"上次执行") */
.table { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); overflow: hidden; } .table { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); overflow: hidden; }
.table-head, .table-row { display: grid; grid-template-columns: 2fr 1.2fr 1fr 1.5fr 1fr 1.2fr 1.5fr; gap: 12px; padding: 12px 18px; align-items: center; } .table-head, .table-row { display: grid; grid-template-columns: 2fr 1.1fr 0.9fr 1.4fr 0.9fr 1.3fr 1.2fr 1.6fr; gap: 10px; padding: 12px 16px; align-items: center; }
.table-head { background: var(--gray-50); font-size: 13px; color: var(--gray-500); font-weight: 600; border-bottom: 1px solid var(--gray-200); } .table-head { background: var(--gray-50); font-size: 13px; color: var(--gray-500); font-weight: 600; border-bottom: 1px solid var(--gray-200); }
.table-row { border-bottom: 1px solid var(--gray-100); font-size: 14px; } .table-row { border-bottom: 1px solid var(--gray-100); font-size: 14px; }
.table-row:last-child { border-bottom: none; } .table-row:last-child { border-bottom: none; }
.name { font-weight: 600; color: var(--gray-900); } .name { font-weight: 600; color: var(--gray-900); }
.schedule-desc { color: var(--gray-700); font-size: 13px; } .schedule-desc { color: var(--gray-700); font-size: 13px; }
.schedule-desc .date-range { color: var(--gray-500); font-size: 12px; } .schedule-desc .date-range { color: var(--gray-500); font-size: 12px; }
/* 状态徽章(启用/禁用) */
.status-badge { font-size: 12px; padding: 3px 10px; border-radius: 6px; font-weight: 600; } .status-badge { font-size: 12px; padding: 3px 10px; border-radius: 6px; font-weight: 600; }
.badge-enabled { background: #dcfce7; color: #15803d; } .badge-enabled { background: #dcfce7; color: #15803d; }
.badge-disabled { background: var(--gray-100); color: var(--gray-500); } .badge-disabled { background: var(--gray-100); color: var(--gray-500); }
.actions { display: flex; gap: 8px; }
.btn-sm { padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-block; } /* 执行状态徽章 */
.badge-running { background: #dbeafe; color: #1d4ed8; }
.badge-success { background: #dcfce7; color: #15803d; }
.badge-failed, .badge-error { background: #fee2e2; color: #dc2626; }
.badge-idle { background: var(--gray-100); color: var(--gray-500); }
/* 上次执行单元格 */
.last-run { display: flex; flex-direction: column; gap: 3px; }
.last-run-time { font-size: 12px; color: var(--gray-700); }
.last-run-actions { display: flex; align-items: center; gap: 6px; }
.report-link { font-size: 12px; color: var(--primary); text-decoration: none; }
.report-link:hover { text-decoration: underline; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.btn-sm { padding: 5px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-block; }
.btn-run { background: #dbeafe; color: #1d4ed8; }
.btn-toggle { background: #fef3c7; color: #92400e; } .btn-toggle { background: #fef3c7; color: #92400e; }
.btn-edit { background: var(--gray-100); color: var(--gray-700); } .btn-edit { background: var(--gray-100); color: var(--gray-700); }
.btn-del { background: #fee2e2; color: var(--red); } .btn-del { background: #fee2e2; color: var(--red); }
...@@ -62,8 +79,10 @@ ...@@ -62,8 +79,10 @@
.btn-save:disabled { opacity: .6; cursor: not-allowed; } .btn-save:disabled { opacity: .6; cursor: not-allowed; }
@media screen and (max-width: 1024px) { @media screen and (max-width: 1024px) {
.table-head, .table-row { grid-template-columns: 1fr 1fr 1fr 1fr; } .table-head, .table-row { grid-template-columns: 1fr 1fr 1fr 1.2fr; }
.table-head span:nth-child(5), .table-head span:nth-child(6), .table-row span:nth-child(5), .table-row span:nth-child(6) { display: none; } /* 隐藏:套件(3)、启用(5)、上次执行(6)、下次执行(7) */
.table-head span:nth-child(3), .table-head span:nth-child(5), .table-head span:nth-child(6), .table-head span:nth-child(7),
.table-row > span:nth-child(3), .table-row > span:nth-child(5), .table-row > span:nth-child(6), .table-row > span:nth-child(7) { display: none; }
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.table-head { display: none; } .table-head { display: none; }
...@@ -73,7 +92,6 @@ ...@@ -73,7 +92,6 @@
.date-row { flex-direction: column; gap: 0; } .date-row { flex-direction: column; gap: 0; }
.date-row .form-group { margin-bottom: 14px; } .date-row .form-group { margin-bottom: 14px; }
} }
}
</style> </style>
{% endblock %} {% endblock %}
...@@ -92,7 +110,8 @@ ...@@ -92,7 +110,8 @@
<span>目标</span> <span>目标</span>
<span>套件</span> <span>套件</span>
<span>调度周期</span> <span>调度周期</span>
<span>状态</span> <span>启用</span>
<span>上次执行</span>
<span>下次执行</span> <span>下次执行</span>
<span>操作</span> <span>操作</span>
</div> </div>
...@@ -103,9 +122,23 @@ ...@@ -103,9 +122,23 @@
<span>{{ '快速' if s.suite == 'quick' else '全量' }}</span> <span>{{ '快速' if s.suite == 'quick' else '全量' }}</span>
<span class="schedule-desc">{{ s.description or s.cron }}</span> <span class="schedule-desc">{{ s.description or s.cron }}</span>
<span><span class="status-badge {{ 'badge-enabled' if s.enabled else 'badge-disabled' }}">{{ '启用' if s.enabled else '禁用' }}</span></span> <span><span class="status-badge {{ 'badge-enabled' if s.enabled else 'badge-disabled' }}">{{ '启用' if s.enabled else '禁用' }}</span></span>
<span>{{ s.next_run_at or '-' }}</span> <span class="last-run">
{% if s.last_run_at %}
<span class="last-run-time">{{ s.last_run_at[:16].replace('T', ' ') }}</span>
<div class="last-run-actions">
<span class="status-badge {{ s.run_badge }}">{{ s.run_label }}</span>
{% if s.last_report_id %}
<a class="report-link" href="/service-monitor/report/{{ s.last_report_id }}">报告</a>
{% endif %}
</div>
{% else %}
<span class="last-run-time" style="color: var(--gray-400);">未执行</span>
{% endif %}
</span>
<span>{{ s.next_run_at[:16].replace('T', ' ') if s.next_run_at else '-' }}</span>
<div class="actions"> <div class="actions">
{% if is_admin %} {% if is_admin %}
<button class="btn-sm btn-run" onclick="runSchedule('{{ s.id }}')">执行</button>
<button class="btn-sm btn-toggle" onclick="toggleSchedule('{{ s.id }}')">{{ '禁用' if s.enabled else '启用' }}</button> <button class="btn-sm btn-toggle" onclick="toggleSchedule('{{ s.id }}')">{{ '禁用' if s.enabled else '启用' }}</button>
<button class="btn-sm btn-edit" onclick="openEdit('{{ s.id }}')">编辑</button> <button class="btn-sm btn-edit" onclick="openEdit('{{ s.id }}')">编辑</button>
<button class="btn-sm btn-del" onclick="deleteSchedule('{{ s.id }}')">删除</button> <button class="btn-sm btn-del" onclick="deleteSchedule('{{ s.id }}')">删除</button>
...@@ -206,6 +239,16 @@ const SCHEDULES_DATA = {{ schedules | tojson | safe }}; ...@@ -206,6 +239,16 @@ const SCHEDULES_DATA = {{ schedules | tojson | safe }};
let editingId = null; let editingId = null;
let currentMode = 'daily'; let currentMode = 'daily';
let selectedWeekdays = new Set(); let selectedWeekdays = new Set();
let refreshTimer = null;
// 状态映射(与后端 routes.py 同步)
const STATUS_MAP = {
running: {badge: 'badge-running', label: '🔄 执行中'},
success: {badge: 'badge-success', label: '✅ 成功'},
failed: {badge: 'badge-failed', label: '❌ 失败'},
error: {badge: 'badge-error', label: '⚠️ 异常'},
idle: {badge: 'badge-idle', label: '⏸ 待执行'},
};
// 初始化时间选择器 // 初始化时间选择器
(function initTimePicker() { (function initTimePicker() {
...@@ -217,7 +260,7 @@ let selectedWeekdays = new Set(); ...@@ -217,7 +260,7 @@ let selectedWeekdays = new Set();
opt.textContent = String(h).padStart(2, '0'); opt.textContent = String(h).padStart(2, '0');
hourSel.appendChild(opt); hourSel.appendChild(opt);
} }
// 分钟:整刻度 + 每5分钟 // 分钟:每5分钟
for (let m = 0; m < 60; m += 5) { for (let m = 0; m < 60; m += 5) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = m; opt.value = m;
...@@ -229,6 +272,69 @@ let selectedWeekdays = new Set(); ...@@ -229,6 +272,69 @@ let selectedWeekdays = new Set();
minSel.value = 0; minSel.value = 0;
})(); })();
// 页面加载时检查是否有 running 状态,有则启动轮询
(function initAutoRefresh() {
const hasRunning = SCHEDULES_DATA.some(s => s.current_status === 'running');
if (hasRunning) startAutoRefresh();
})();
function startAutoRefresh() {
if (refreshTimer) return;
refreshTimer = setInterval(refreshSchedules, 3000);
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
async function refreshSchedules() {
try {
const r = await fetch('/api/service-monitor/schedules', {credentials: 'include'});
const d = await r.json();
if (!d.success) return;
const schedules = d.schedules || [];
let hasRunning = false;
schedules.forEach(s => {
const row = document.querySelector(`.table-row[data-id="${s.id}"]`);
if (!row) return;
const statusKey = s.current_status || s.last_run_status || 'idle';
const info = STATUS_MAP[statusKey] || STATUS_MAP.idle;
const lastRunCell = row.querySelector('.last-run');
if (s.last_run_at) {
lastRunCell.innerHTML = `
<span class="last-run-time">${s.last_run_at.slice(0, 16).replace('T', ' ')}</span>
<div class="last-run-actions">
<span class="status-badge ${info.badge}">${info.label}</span>
${s.last_report_id ? `<a class="report-link" href="/service-monitor/report/${s.last_report_id}">报告</a>` : ''}
</div>
`;
} else {
lastRunCell.innerHTML = `<span class="last-run-time" style="color: var(--gray-400);">未执行</span>`;
}
// 更新下次执行时间
const nextRunSpan = row.querySelector('span:nth-child(7)');
if (nextRunSpan && s.next_run_at) {
nextRunSpan.textContent = s.next_run_at.slice(0, 16).replace('T', ' ');
}
if (s.current_status === 'running') hasRunning = true;
});
// 无 running 状态则停止轮询
if (!hasRunning) stopAutoRefresh();
} catch (e) {
console.error('刷新失败', e);
}
}
function setRepeatMode(mode) { function setRepeatMode(mode) {
currentMode = mode; currentMode = mode;
document.querySelectorAll('.repeat-btn').forEach(btn => { document.querySelectorAll('.repeat-btn').forEach(btn => {
...@@ -376,6 +482,42 @@ async function saveSchedule() { ...@@ -376,6 +482,42 @@ async function saveSchedule() {
} }
} }
async function runSchedule(id) {
const row = document.querySelector(`.table-row[data-id="${id}"]`);
const btn = row.querySelector('.btn-run');
const lastRunCell = row.querySelector('.last-run');
// 按钮禁用防止重复点击
btn.disabled = true;
btn.textContent = '执行中...';
try {
const r = await fetch('/api/service-monitor/schedules/' + id + '/run', {
method: 'POST',
credentials: 'include',
});
const d = await r.json();
if (d.success) {
// 立即更新徽章为"执行中"
lastRunCell.innerHTML = `
<span class="last-run-time">刚刚</span>
<div class="last-run-actions">
<span class="status-badge badge-running">🔄 执行中</span>
</div>
`;
// 启动轮询
startAutoRefresh();
} else {
alert(d.error?.message || '执行失败');
}
} catch (e) {
alert('请求失败');
} finally {
btn.disabled = false;
btn.textContent = '执行';
}
}
async function toggleSchedule(id) { async function toggleSchedule(id) {
try { try {
const r = await fetch('/api/service-monitor/schedules/' + id + '/toggle', { const r = await fetch('/api/service-monitor/schedules/' + id + '/toggle', {
...@@ -412,4 +554,4 @@ async function deleteSchedule(id) { ...@@ -412,4 +554,4 @@ async function deleteSchedule(id) {
} }
} }
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论