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

feat(service-monitor): 定时自动巡检功能(APScheduler + cron 表达式)

- 新增 schedule_service.py:定时任务 CRUD + APScheduler BackgroundScheduler 集成
- 支持 cron 表达式校验(croniter)+ 下次执行时间计算
- 新增 runner_service.run_inspection_sync() 同步执行函数
- 新增 schedule.html 定时任务管理页:表格展示 + 新增/编辑弹窗 + Cron 预设
- 新增 API 路由:schedules CRUD + toggle 启停
- server.py 初始化 APScheduler
- upload_to_server.py 增加 apscheduler 依赖
- PRD 需求文档 + 计划执行文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 f3893286
# 执行计划 — 左侧菜单布局改造与定时自动巡检
> 版本:v1.0 | 日期:2026-07-20
> 对应需求:`Docs/需求文档/服务监测/PRD_需求文档_左侧菜单与定时巡检.md`
> 依赖:`apscheduler`、`croniter`
---
## 0. 执行原则
- **模板继承优先**:创建 `base.html` 共享骨架,所有页面继承,减少重复代码
- **自包含子包**:所有代码写在 `service_monitor/` 内,仅 `server.py` 增加 1 行初始化调用
- **渐进式改造**:先改布局(阶段一),再加功能(阶段二),降低风险
- **JSON 文件存储**:定时任务配置存 `schedules.json`,与 `targets.json` 同级
---
## 阶段一:左侧菜单布局改造
### 1.1 创建共享基础模板
**文件**: `skill/code/web/templates/service_monitor/base.html`
**模板结构**:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5">
<title>{% block title %}服务监测{% endblock %} - 运行维护平台</title>
<style>
/* CSS 变量(与现有一致) */
:root { --primary: #2563eb; --primary-hover: #1d4ed8; ... }
/* 侧边栏样式 */
.sidebar { position: fixed; left: 0; top: 0; width: 200px; height: 100vh;
background: linear-gradient(180deg, #1e293b 0%, #334155 100%); ... }
.sidebar-brand { color: #fff; font-size: 18px; font-weight: 700; ... }
.sidebar-nav { padding: 16px 0; }
.nav-item { display: block; padding: 12px 20px; color: #94a3b8; text-decoration: none; }
.nav-item:hover { background: rgba(255,255,255,.05); color: #fff; }
.nav-item.active { background: var(--primary); color: #fff; }
.nav-icon { margin-right: 10px; }
/* 顶部条 */
.topbar { position: fixed; left: 200px; top: 0; right: 0; height: 56px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); ... }
.hamburger { display: none; } /* 移动端显示 */
.topbar-home { ... }
.topbar-user { ... }
/* 主内容区 */
.main-content { margin-left: 200px; padding: 28px 24px; min-height: calc(100vh - 56px); }
/* 移动端响应式 */
@media screen and (max-width: 768px) {
.sidebar { transform: translateX(-200px); }
.sidebar.open { transform: translateX(0); }
.main-content { margin-left: 0; }
.topbar { left: 0; }
.hamburger { display: block; }
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- 侧边栏 -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">🛠️ 运维</div>
<nav class="sidebar-nav">
<a href="/service-monitor" class="nav-item {{ 'active' if active_menu == 'targets' }}">
<span class="nav-icon">📊</span>监测目标
</a>
<a href="/service-monitor/reports" class="nav-item {{ 'active' if active_menu == 'reports' }}">
<span class="nav-icon">📋</span>巡检报告
</a>
<a href="/service-monitor/schedule" class="nav-item {{ 'active' if active_menu == 'schedule' }}">
<span class="nav-icon"></span>定时任务
</a>
{% if is_admin %}
<a href="/service-monitor/targets" class="nav-item {{ 'active' if active_menu == 'manage' }}">
<span class="nav-icon">⚙️</span>目标管理
</a>
{% endif %}
</nav>
</aside>
<!-- 顶部条 -->
<header class="topbar">
<button class="hamburger" id="hamburger" onclick="toggleSidebar()"></button>
<a href="/" class="topbar-home">🏠 返回首页</a>
<div class="topbar-user">
<span>{{ user.username }}({{ '管理员' if is_admin else '用户' }})</span>
<button class="btn-logout" onclick="handleLogout()">🚪 退出</button>
</div>
</header>
<!-- 主内容区 -->
<main class="main-content">
{% block content %}{% endblock %}
</main>
<script>
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
}
async function handleLogout() {
try { await fetch('/logout', {method:'POST', credentials:'include'}); } catch(e) {}
window.location.href = '/login';
}
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
```
### 1.2 改造 index.html
**文件**: `skill/code/web/templates/service_monitor/index.html`
**改动点**:
1. 删除完整 HTML 外壳,改为继承 base.html
2. 删除 navbar 部分(已在 base.html)
3. 只保留监测目标卡片网格
4. 删除"最近巡检报告"区块(迁移到 reports.html)
**改造后**:
```html
{% extends "service_monitor/base.html" %}
{% block title %}监测目标{% endblock %}
{% block content %}
<div class="section-head">
<div class="section-title">📊 监测目标</div>
{% if is_admin %}
<a href="/service-monitor/targets" class="btn-add">+ 目标管理</a>
{% endif %}
</div>
{% if targets %}
<div class="target-grid">
{% for t in targets %}
<div class="target-card">
<!-- 目标卡片内容(保持不变) -->
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">📭 暂无监测目标</div>
{% endif %}
{% endblock %}
```
### 1.3 新建 reports.html
**文件**: `skill/code/web/templates/service_monitor/reports.html`(新增)
从原 `index.html` 的"最近巡检报告"区块搬出,增强为独立页面:
- 支持按目标筛选
- 支持分页(每页 20 条)
- 显示报告总数
### 1.4 改造 targets.html
**文件**: `skill/code/web/templates/service_monitor/targets.html`
**改动点**:
1. 删除完整 HTML 外壳,改为继承 base.html
2. 删除 navbar 部分
3. 保留表格 + 弹窗(目标管理的核心功能)
### 1.5 改造 run.html
**文件**: `skill/code/web/templates/service_monitor/run.html`
**改动点**:
1. 继承 base.html
2. 保留巡检进度展示(SSE 逻辑不变)
### 1.6 改造 report.html
**文件**: `skill/code/web/templates/service_monitor/report.html`
**改动点**:
1. 继承 base.html
2. 保留报告详情展示(折叠展示逻辑不变)
### 1.7 新增路由
**文件**: `skill/code/web/service_monitor/routes.py`
新增报告列表页路由:
```python
@bp.route('/service-monitor/reports')
def page_reports():
"""巡检报告列表页(所有登录用户可看)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
target_id = request.args.get('target_id')
reports = report_service.list_reports(target_id=target_id, limit=50)
targets = target_service.list_targets(role=user.get('role', ''))
return render_template(
'service_monitor/reports.html',
user=user, reports=reports, targets=targets,
is_admin=(user.get('role') == 'admin'),
active_menu='reports',
)
```
修改现有路由,增加 `active_menu` 参数:
```python
# page_index() 增加 active_menu='targets'
# page_targets() 增加 active_menu='manage'
# page_run() 增加 active_menu='targets'
# page_report() 增加 active_menu='reports'
```
---
## 阶段二:定时自动巡检功能
### 2.1 新增路径常量
**文件**: `skill/code/web/service_monitor/utils/paths.py`
**改动点**:
```python
# 新增
SCHEDULES_FILE = DATA_DIR / "schedules.json"
```
### 2.2 新增定时任务服务
**文件**: `skill/code/web/service_monitor/services/schedule_service.py`(新增)
**核心函数**:
```python
# 数据读写
def _load_schedules() -> list
def _save_schedules(schedules: list) -> None
# CRUD
def list_schedules() -> list
def get_schedule(schedule_id: str) -> Optional[dict]
def create_schedule(data: dict, created_by: str) -> dict
def update_schedule(schedule_id: str, data: dict) -> dict
def delete_schedule(schedule_id: str) -> bool
# 启停
def toggle_enabled(schedule_id: str) -> dict
# 执行状态更新
def update_run_status(schedule_id: str, status: str, report_id: str, run_at: str) -> dict
# APScheduler 集成
def init_scheduler(app)
def _reload_jobs()
def _add_job(sched: dict)
def _remove_job(schedule_id: str)
def _execute_scheduled_job(schedule_id: str)
```
**init_scheduler 实现**:
```python
_scheduler: BackgroundScheduler = None
def init_scheduler(app):
global _scheduler
if _scheduler is not None:
return
_scheduler = BackgroundScheduler()
_scheduler.start()
_reload_jobs()
def _reload_jobs():
schedules = list_schedules()
for sched in schedules:
if sched.get('enabled'):
_add_job(sched)
def _add_job(sched: dict):
from apscheduler.triggers.cron import CronTrigger
job_id = f"schedule_{sched['id']}"
trigger = CronTrigger.from_crontab(sched['cron'])
_scheduler.add_job(
func=_execute_scheduled_job,
trigger=trigger,
id=job_id,
args=[sched['id']],
replace_existing=True,
)
def _execute_scheduled_job(schedule_id: str):
"""定时任务执行入口(在 APScheduler 线程中调用)。"""
sched = get_schedule(schedule_id)
if not sched or not sched.get('enabled'):
return
# 调用 runner_service 执行巡检(同步版本)
result = runner_service.run_inspection_sync(
sched['target_id'], sched['suite']
)
# 更新执行状态
update_run_status(
schedule_id,
status='success' if result.get('success') else 'failed',
report_id=result.get('report_id'),
run_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
)
```
### 2.3 新增 runner_service 同步执行函数
**文件**: `skill/code/web/service_monitor/services/runner_service.py`
**改动点**:
新增同步执行函数(供定时任务调用):
```python
def run_inspection_sync(target_id: str, suite: str) -> dict:
"""同步执行巡检(定时任务调用),返回结果摘要。
与 run_inspection() 生成器共享核心逻辑,但不 yield SSE 事件。
"""
# 复用现有逻辑:选择执行器、渲染 config、执行模块、解析输出、保存报告
# 返回 {"success": bool, "report_id": str, "summary": dict}
```
### 2.4 更新服务层导出
**文件**: `skill/code/web/service_monitor/services/__init__.py`
**改动点**:
```python
from . import target_service
from . import report_service
from . import runner_service
from . import schedule_service # 新增
```
### 2.5 新增路由
**文件**: `skill/code/web/service_monitor/routes.py`
新增定时任务页面路由:
```python
@bp.route('/service-monitor/schedule')
def page_schedule():
"""定时任务管理页(所有登录用户可看,操作仅管理员)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
schedules = schedule_service.list_schedules()
targets = target_service.list_targets(role=user.get('role', ''))
return render_template(
'service_monitor/schedule.html',
user=user, schedules=schedules, targets=targets,
is_admin=(user.get('role') == 'admin'),
active_menu='schedule',
)
```
新增 API 路由:
```python
# 列出定时任务
@bp.route('/api/service-monitor/schedules', methods=['GET'])
def api_list_schedules():
guard = _require_login_json()
if guard: return guard
schedules = schedule_service.list_schedules()
return jsonify({"success": True, "schedules": schedules})
# 创建定时任务
@bp.route('/api/service-monitor/schedules', methods=['POST'])
def api_create_schedule():
guard = _require_admin_json()
if guard: return guard
try:
sched = schedule_service.create_schedule(
request.get_json(force=True) or {},
created_by=_current_user().get('username', 'admin'),
)
return jsonify({"success": True, "schedule": sched})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
# 更新定时任务
@bp.route('/api/service-monitor/schedules/<schedule_id>', methods=['PUT'])
def api_update_schedule(schedule_id):
guard = _require_admin_json()
if guard: return guard
try:
sched = schedule_service.update_schedule(schedule_id, request.get_json(force=True) or {})
return jsonify({"success": True, "schedule": sched})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
# 删除定时任务
@bp.route('/api/service-monitor/schedules/<schedule_id>', methods=['DELETE'])
def api_delete_schedule(schedule_id):
guard = _require_admin_json()
if guard: return guard
ok = schedule_service.delete_schedule(schedule_id)
return jsonify({"success": ok})
# 启停切换
@bp.route('/api/service-monitor/schedules/<schedule_id>/toggle', methods=['POST'])
def api_toggle_schedule(schedule_id):
guard = _require_admin_json()
if guard: return guard
sched = schedule_service.toggle_enabled(schedule_id)
return jsonify({"success": True, "schedule": sched})
```
### 2.6 初始化 APScheduler
**文件**: `skill/code/web/server.py`
**改动点**:
`create_app()` 末尾添加:
```python
# 初始化服务监测定时调度
from service_monitor.services import schedule_service
schedule_service.init_scheduler(app)
```
### 2.7 部署依赖更新
**文件**: `deploy/upload_to_server.py`
**改动点**:
```python
_REQUIRED_PACKAGES = ['paramiko', 'cryptography', 'apscheduler']
```
---
## 阶段三:定时任务前端页面
### 3.1 新增定时任务管理页
**文件**: `skill/code/web/templates/service_monitor/schedule.html`(新增)
继承 `base.html`,包含:
- 表格展示定时任务列表
- 新增/编辑弹窗(任务名、目标下拉、套件选择、cron 表达式)
- 启用/禁用切换按钮
- 删除按钮(确认弹窗)
- Cron 预设选择 + 自定义输入
**关键代码**:
```html
{% extends "service_monitor/base.html" %}
{% block title %}定时任务{% endblock %}
{% block content %}
<div class="section-head">
<div class="section-title">⏰ 定时任务</div>
{% if is_admin %}
<button class="btn-add" onclick="openCreate()">+ 新增定时任务</button>
{% endif %}
</div>
<div class="schedule-table">
<div class="table-head">
<span>任务名称</span>
<span>目标</span>
<span>套件</span>
<span>Cron</span>
<span>状态</span>
<span>下次执行</span>
<span>最近执行</span>
{% if is_admin %}<span>操作</span>{% endif %}
</div>
{% for s in schedules %}
<div class="table-row">
<span class="name">{{ s.name }}</span>
<span>{{ s.target_name or s.target_id }}</span>
<span>{{ '快速' if s.suite == 'quick' else '全量' }}</span>
<span class="cron">{{ s.cron }}</span>
<span class="status {{ 'enabled' if s.enabled else 'disabled' }}">
{{ '启用' if s.enabled else '禁用' }}
</span>
<span>{{ s.next_run_at or '-' }}</span>
<span>{{ s.last_run_status or '-' }}</span>
{% if is_admin %}
<div class="actions">
<button class="btn-sm" 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-del" onclick="deleteSchedule('{{ s.id }}')">删除</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- 新增/编辑弹窗 -->
<div class="modal-mask" id="modal">...</div>
{% endblock %}
{% block extra_js %}
<script>
const TARGETS = {{ targets | tojson | safe }};
const CRON_PRESETS = {
'0 8 * * *': '每天 08:00',
'0 */6 * * *': '每 6 小时',
'0 8 * * 1': '每周一 08:00',
};
// CRUD 操作 JS ...
</script>
{% endblock %}
```
---
## 阶段四:测试与部署
### 4.1 本地验证
1. 启动服务:`python skill/code/web/server.py`
2. 访问 `/service-monitor`,确认左侧菜单显示
3. 点击各菜单项,确认页面切换正常
4. 移动端模拟(Chrome DevTools),确认汉堡菜单可用
5. 创建定时任务(设 1 分钟后触发),确认自动执行
6. 启停/删除定时任务,确认 APScheduler 同步
### 4.2 部署到 5.60
1. 执行 `upload_to_server.py`,确认 `apscheduler` 安装成功
2. 重启服务:`systemctl restart troubleshoot`
3. 验证定时任务页面可访问
4. 创建定时任务,等待触发验证
---
## 风险与避坑
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| APScheduler 与 Flask 多线程 | GIL 限制并发 | 巡检是 IO 密集型(SSH 等待),影响可接受 |
| 模板继承改造遗漏 | 样式/脚本丢失 | 逐文件对比改造前后,确保功能不变 |
| 定时任务并发执行 | 同一目标多任务同时执行 | `_runs` 注册表已隔离,SSH 连接竞争可接受(只读操作) |
| Cron 表达式解析错误 | 任务无法创建 | 用 `croniter` 校验,返回友好错误提示 |
| 移动端侧边栏交互 | 触摸体验差 | 增加遮罩层点击关闭、ESC 键关闭 |
---
## 实施顺序与里程碑
| 步骤 | 内容 | 产出 |
|------|------|------|
| 1 | 创建 `base.html` | 共享骨架模板 |
| 2 | 改造 4 个页面继承 base.html | 页面布局统一 |
| 3 | 新建 `reports.html` + 路由 | 报告列表独立页 |
| 4 | 新增 `schedule_service.py` | 定时任务数据服务 |
| 5 | 新增 `runner_service.run_inspection_sync()` | 同步执行函数 |
| 6 | 新增定时任务 API 路由 | 定时任务 CRUD |
| 7 | `server.py` 初始化 APScheduler | 调度器启动 |
| 8 | 新建 `schedule.html` | 定时任务管理页 |
| 9 | `upload_to_server.py` 增加依赖 | 部署准备 |
| 10 | 本地验证 + 部署验证 | 全链路通 |
---
## 文件清单
| 操作 | 文件 |
|------|------|
| 新增 | `templates/service_monitor/base.html` |
| 新增 | `templates/service_monitor/reports.html` |
| 新增 | `templates/service_monitor/schedule.html` |
| 新增 | `service_monitor/services/schedule_service.py` |
| 修改 | `templates/service_monitor/index.html` |
| 修改 | `templates/service_monitor/targets.html` |
| 修改 | `templates/service_monitor/run.html` |
| 修改 | `templates/service_monitor/report.html` |
| 修改 | `service_monitor/routes.py` |
| 修改 | `service_monitor/services/__init__.py` |
| 修改 | `service_monitor/services/runner_service.py` |
| 修改 | `service_monitor/utils/paths.py` |
| 修改 | `server.py` |
| 修改 | `deploy/upload_to_server.py` |
# PRD — 左侧菜单布局改造与定时自动巡检
> 版本:v1.0 | 日期:2026-07-20 | 模块 ID:`service-monitor-layout-schedule`
> 依赖:APScheduler 3.x、croniter
---
## 1. 背景与目标
### 1.1 背景
服务监测模块已上线运行,检测模块从 17 个扩展到 30 个,功能入口逐渐增多。当前页面布局存在以下问题:
1. **布局拥挤**:【监测目标】和【巡检报告】挤在 index.html 一个页面,信息密度高
2. **导航不直观**:【目标管理】通过顶部按钮跳转,用户难以快速定位功能入口
3. **扩展性差**:新增功能(如定时任务)无处安放,只能继续往单页堆叠
4. **定时巡检缺失**:当前巡检完全手动触发,无法设置定时自动巡检
### 1.2 目标
1. **左侧菜单布局**:将页面改为左侧固定菜单 + 右侧内容区的经典后台布局,提升导航体验和可扩展性
2. **定时自动巡检**:支持配置定时巡检任务(cron 表达式),到时间自动触发巡检并生成报告
3. **页面拆分**:将【监测目标】和【巡检报告】拆为独立页面,各司其职
### 1.3 用户角色
| 角色 | 权限 |
|------|------|
| **管理员** | 可配置定时任务(增删改启停)、可管理监测目标、可触发巡检、可查看报告 |
| **普通用户** | 仅可查看监测目标、巡检报告、定时任务列表(不可操作) |
---
## 2. 核心概念
| 概念 | 说明 |
|------|------|
| **左侧菜单 (Sidebar)** | 固定在页面左侧的导航菜单,包含所有功能入口 |
| **基础模板 (Base Template)** | Jinja2 模板继承的父模板,包含侧边栏 + 顶部条 + 内容区骨架 |
| **定时任务 (Schedule)** | 一条定时巡检配置:目标 + 套件 + cron 表达式 + 启停状态 |
| **Cron 表达式** | 标准 5 段格式(分 时 日 月 周),如 `0 8 * * *` 表示每天 8:00 |
| **调度器 (Scheduler)** | APScheduler BackgroundScheduler,负责按 cron 表达式触发巡检 |
---
## 3. 功能需求
### 3.1 左侧菜单布局
- [F1] **侧边栏导航**:左侧固定菜单,包含 4 个入口(监测目标、巡检报告、定时任务、目标管理)
- [F2] **当前页高亮**:当前活跃菜单项视觉高亮,用户一目了然当前位置
- [F3] **权限控制**:目标管理菜单项仅管理员可见
- [F4] **移动端适配**:768px 以下侧边栏隐藏,顶部显示汉堡菜单按钮,点击滑出侧边栏
- [F5] **模板继承**:创建共享基础模板,所有页面继承,减少重复代码
### 3.2 页面拆分
- [F6] **监测目标页**`/service-monitor`,只展示目标卡片网格 + 巡检入口
- [F7] **巡检报告页**`/service-monitor/reports`,独立展示巡检报告列表(从 index.html 拆出)
- [F8] **报告列表增强**:支持按目标筛选、分页浏览
### 3.3 定时自动巡检
- [F9] **定时任务列表**`/service-monitor/schedule`,展示所有定时任务(名称、目标、套件、cron、状态、下次执行时间)
- [F10] **创建定时任务**:管理员可创建定时任务(选择目标 + 套件 + cron 表达式)
- [F11] **编辑定时任务**:管理员可修改定时任务的名称、cron 表达式等
- [F12] **启用/禁用**:管理员可切换定时任务的启用/禁用状态
- [F13] **删除定时任务**:管理员可删除定时任务
- [F14] **Cron 辅助**:提供常用 cron 预设(每天/每周/每小时)+ 自定义输入 + 实时校验
- [F15] **自动执行**:到时间自动触发巡检,结果保存为报告
- [F16] **执行状态**:定时任务显示最近执行时间、执行状态、关联报告 ID
### 3.4 预留功能
- [F-预留] **执行历史**:定时任务的完整执行历史记录(本期仅显示最近一次)
---
## 4. 关键设计
### 4.1 布局结构
```
┌──────────┬──────────────────────────────┐
│ 🛠️ 运维 │ ☰ 🏠 返回首页 用户名 退出 │
│──────────│──────────────────────────────│
│ 📊 监测 │ │
│ 目标 │ │
│ 📋 报告 │ 主内容区域 │
│ ⏰ 定时 │ {% block content %} │
│ │ {% endblock %} │
│ ⚙️ 目标 │ │
│ 管理 │ │
└──────────┴──────────────────────────────┘
```
| 维度 | 设计 |
|------|------|
| 侧边栏宽度 | 200px,固定定位 |
| 侧边栏背景 | 深色渐变 `#1e293b → #334155` |
| 顶部条 | 保持现有渐变风格,去掉面包屑 |
| 内容区 | `margin-left: 200px``max-width: 1100px` |
| 移动端 | 768px 以下侧边栏隐藏,汉堡菜单滑出 |
### 4.2 菜单项定义
| 菜单项 | 路由 | 图标 | 权限 | active_menu 值 |
|--------|------|------|------|----------------|
| 监测目标 | `/service-monitor` | 📊 | 所有用户 | `targets` |
| 巡检报告 | `/service-monitor/reports` | 📋 | 所有用户 | `reports` |
| 定时任务 | `/service-monitor/schedule` | ⏰ | 所有用户 | `schedule` |
| 目标管理 | `/service-monitor/targets` | ⚙️ | 仅管理员 | `manage` |
### 4.3 定时任务数据模型
存储文件:`service_monitor/data/schedules.json`
```json
[{
"id": "sched_abc12345",
"name": "每日全量巡检",
"target_id": "local",
"target_name": "本机(当前服务器)",
"suite": "full",
"cron": "0 8 * * *",
"enabled": true,
"last_run_at": null,
"last_run_status": null,
"last_report_id": null,
"next_run_at": "2026-07-21T08:00:00",
"created_at": "2026-07-20T10:00:00",
"created_by": "admin"
}]
```
### 4.4 APScheduler 集成方案
| 维度 | 方案 |
|------|------|
| 调度器类型 | `BackgroundScheduler`(APScheduler 3.x) |
| 触发器 | `CronTrigger.from_crontab()` 解析 5 段 cron |
| Job ID | `schedule_{schedule_id}` |
| 初始化时机 | Flask `create_app()` 末尾 |
| 持久化 | 内存 + JSON 文件手动同步,服务重启从 `schedules.json` 重新加载 |
| 执行函数 | 调用 `runner_service.run_inspection_sync()` 同步执行巡检 |
### 4.5 Cron 表达式校验
| 维度 | 方案 |
|------|------|
| 校验库 | `croniter`(轻量 cron 解析库) |
| 前端预设 | 每天 08:00 / 每 6 小时 / 每周一 08:00 / 自定义 |
| 后端校验 | `croniter` 解析失败抛异常,返回友好错误提示 |
| 下次执行时间 | `croniter.get_next()` 计算 |
---
## 5. 架构原则
### 5.1 模板继承体系
```
base.html(共享骨架:侧边栏 + 顶部条 + 内容区)
├── index.html(监测目标)
├── reports.html(巡检报告)
├── schedule.html(定时任务)
├── targets.html(目标管理)
├── run.html(巡检执行)
└── report.html(报告详情)
```
### 5.2 自包含子包原则(不变)
所有代码写在 `skill/code/web/service_monitor/` 子包内,不动 `routes/``services/``utils/`
唯一例外:`server.py` 需增加 APScheduler 初始化调用(1 行代码)。
### 5.3 对外接口
| 接入点 | 位置 | 代码 |
|--------|------|------|
| Blueprint 注册 | `server.py` | `from service_monitor import bp; app.register_blueprint(bp)` |
| APScheduler 初始化 | `server.py` | `from service_monitor.services import schedule_service; schedule_service.init_scheduler(app)` |
---
## 6. 关键流程
### 6.1 创建定时任务
1. 管理员在定时任务页点击"新增"
2. 填写任务名、选择目标、选择套件、输入 cron 表达式
3. 前端调用 `POST /api/service-monitor/schedules`
4. `schedule_service.create_schedule()` 校验 cron → 生成 ID → 保存到 `schedules.json`
5. `schedule_service.init_scheduler()` 注册 APScheduler job
6. 返回定时任务详情,前端刷新列表
### 6.2 定时任务自动执行
1. APScheduler 到时间触发 `_execute_scheduled_job()`
2. 调用 `runner_service.run_inspection_sync(target_id, suite)`
3. 巡检执行完成,`report_service.save()` 保存报告
4. 更新 `schedules.json` 中的 `last_run_at` / `last_run_status` / `last_report_id`
5. 更新 `next_run_at`
### 6.3 启停定时任务
1. 管理员点击"禁用/启用"按钮
2. 前端调用 `POST /api/service-monitor/schedules/<id>/toggle`
3. `schedule_service.toggle_enabled()` 更新 `schedules.json`
4. 禁用:`scheduler.remove_job()`;启用:`scheduler.add_job()`
---
## 7. 安全与约束
- 定时任务操作(增删改启停)**仅管理员**可执行
- Cron 表达式需后端校验,防止恶意输入
- 定时巡检执行与手动巡检共享 `runner_service`,并发安全由 `_runs` 注册表保证
- SSH 凭据解密仅在执行时进行,不在定时任务配置中暴露
---
## 8. 决策确认
| 编号 | 决策 |
|------|------|
| Q1 | 布局采用左侧固定侧边栏(非可折叠),移动端用汉堡菜单 |
| Q2 | 定时任务用 APScheduler BackgroundScheduler(非 Celery,避免引入消息队列) |
| Q3 | Cron 表达式用 croniter 校验(APScheduler 内置 CronTrigger 也可解析,但 croniter 提供更友好的错误提示和 next_run 计算) |
| Q4 | 定时任务数据存 JSON 文件(与 targets.json 同级),不用数据库 |
| Q5 | 巡检报告页从 index.html 拆出为独立页面 `/service-monitor/reports` |
| Q6 | 模板继承用 Jinja2 `{% extends %}` + `{% block %}`,创建 `base.html` 共享骨架 |
| Q7 | APScheduler 初始化在 `create_app()` 末尾,不在 blueprint 导入时 |
---
## 9. 验收标准
- [ ] 左侧菜单正确显示 4 个入口,当前页高亮
- [ ] 目标管理菜单仅管理员可见
- [ ] 移动端(768px 以下)汉堡菜单可用,侧边栏滑出/收起
- [ ] 监测目标页只展示目标卡片,巡检报告页独立展示报告列表
- [ ] 管理员可创建/编辑/删除/启停定时任务
- [ ] Cron 表达式校验:合法表达式通过,非法表达式返回友好错误
- [ ] 定时任务到时间自动触发巡检,生成报告
- [ ] 定时任务列表显示下次执行时间、最近执行状态
- [ ] 服务重启后,已启用的定时任务自动恢复调度
- [ ] 部署到 5.60,APScheduler 安装成功,定时巡检全链路通
......@@ -67,8 +67,8 @@ _EXCLUDE_SUFFIXES = ('.pyc', '.pyo')
# 需要自动转换 CRLF→LF 的后缀(bash 脚本在 Linux 服务器执行,不能有 \r)
_UNIX_LINE_END_SUFFIXES = ('.sh', '.template')
# 服务监测模块需要的 Python 依赖(SSH 远程执行)
_REQUIRED_PACKAGES = ['paramiko', 'cryptography']
# 服务监测模块需要的 Python 依赖(SSH 远程执行 + 定时调度
_REQUIRED_PACKAGES = ['paramiko', 'cryptography', 'apscheduler']
def _check_and_install_deps(ssh):
......
......@@ -100,6 +100,10 @@ def create_app():
app.register_blueprint(export_bp)
app.register_blueprint(submit_bp)
# 初始化服务监测定时调度
from service_monitor.services import schedule_service
schedule_service.init_scheduler(app)
return app
......
......@@ -3,3 +3,4 @@
from . import target_service
from . import report_service
from . import runner_service
from . import schedule_service
......@@ -193,3 +193,95 @@ def run_inspection(target_id: str, suite: str):
logger.warning("执行器清理失败: %s", e)
# 延迟移除运行记录,给客户端最后取状态的机会
time.sleep(0)
def run_inspection_sync(target_id: str, suite: str) -> dict:
"""同步执行巡检(定时任务调用),返回结果摘要。
与 run_inspection() 生成器共享核心逻辑,但不 yield SSE 事件。
返回: {"success": bool, "report_id": str, "summary": dict, "finished_at": str}
"""
from datetime import datetime as _dt
run_id = _dt.now().strftime("%Y%m%d_%H%M%S_") + "sched"
target = target_service.get_target(target_id)
if not target:
return {"success": False, "report_id": "", "summary": {}, "finished_at": _now()}
modules = get_suite(suite)
if not modules:
return {"success": False, "report_id": "", "summary": {}, "finished_at": _now()}
_register_run(run_id, target_id, suite, len(modules))
started_at = _now()
# 准备 config(含解密后的凭据)
target_for_render = dict(target)
target_for_render["credential_overrides"] = target_service.resolve_credentials(target)
config_text = render_config(target_for_render)
executor = None
modules_result = []
try:
executor = target_service.make_executor(target, run_id)
# 连通性检查(远程)
if not executor.is_local:
if not executor.test_connection():
_update_run(run_id, finished=True, error="SSH 连接失败")
return {"success": False, "report_id": "", "summary": {}, "finished_at": _now()}
executor.setup()
executor.upload_assets()
for idx, module in enumerate(modules):
# 检查取消
run = _pop_run(run_id)
if run and run["cancel"].is_set():
_update_run(run_id, finished=True)
return {"success": False, "report_id": "", "summary": {}, "finished_at": _now()}
_update_run(run_id, current=module.name, done=idx)
raw = executor.run_module(module, config_text, timeout=90)
items = parse_module_output(raw, module.id, module.category)
summary = summarize(items)
module_result = {
"id": module.id,
"name": module.name,
"category": module.category,
"items": [{"key": it.key, "name": it.name, "value": it.value,
"threshold": it.threshold, "status": it.status}
for it in items],
"summary": summary,
}
modules_result.append(module_result)
_update_run(run_id, done=idx + 1)
report_id = report_service.save(target, suite, modules_result, started_at, _now())
_update_run(run_id, finished=True)
# 汇总
total_summary = {k: 0 for k in ("正常", "警告", "严重", "total")}
for m in modules_result:
ms = m["summary"]
for k in total_summary:
total_summary[k] += ms.get(k, 0)
return {
"success": True,
"report_id": report_id,
"summary": total_summary,
"finished_at": _now(),
}
except Exception as e:
logger.error("同步巡检执行异常: %s\n%s", e, traceback.format_exc())
_update_run(run_id, finished=True, error=str(e))
return {"success": False, "report_id": "", "summary": {}, "finished_at": _now()}
finally:
if executor:
try:
executor.cleanup()
except Exception as e:
logger.warning("执行器清理失败: %s", e)
# -*- coding: utf-8 -*-
"""
schedule_service.py — 定时任务管理(CRUD + APScheduler 集成)
数据存储:service_monitor/data/schedules.json
调度器:APScheduler BackgroundScheduler
"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from datetime import datetime
from typing import Optional
from ..utils.paths import SCHEDULES_FILE, ensure_dirs
logger = logging.getLogger("service_monitor.schedule")
# 全局调度器(延迟初始化)
_scheduler = None
_scheduler_lock = threading.Lock()
def _load_schedules() -> list:
"""加载定时任务列表。"""
if not SCHEDULES_FILE.exists():
return []
try:
data = json.loads(SCHEDULES_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except (json.JSONDecodeError, OSError) as e:
logger.error("加载 schedules.json 失败: %s", e)
return []
def _save_schedules(schedules: list) -> None:
"""保存定时任务列表。"""
ensure_dirs()
SCHEDULES_FILE.write_text(
json.dumps(schedules, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _now() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
def _calc_next_run(cron_expr: str) -> Optional[str]:
"""计算下次执行时间。"""
try:
from croniter import croniter
base = datetime.now()
iter = croniter(cron_expr, base)
next_dt = iter.get_next(datetime)
return next_dt.strftime("%Y-%m-%dT%H:%M:%S")
except Exception as e:
logger.warning("计算下次执行时间失败: %s", e)
return None
# ============================================================
# CRUD
# ============================================================
def list_schedules() -> list:
"""列出所有定时任务。"""
return _load_schedules()
def get_schedule(schedule_id: str) -> Optional[dict]:
"""获取单个定时任务。"""
for s in _load_schedules():
if s.get("id") == schedule_id:
return s
return None
def create_schedule(data: dict, created_by: str) -> dict:
"""创建定时任务。"""
# 校验必填字段
name = data.get("name", "").strip()
target_id = data.get("target_id", "").strip()
suite = data.get("suite", "quick")
cron_expr = data.get("cron", "").strip()
if not name:
raise ValueError("任务名称不能为空")
if not target_id:
raise ValueError("请选择监测目标")
if not cron_expr:
raise ValueError("请输入 Cron 表达式")
# 校验 cron 表达式
try:
from croniter import croniter
croniter(cron_expr) # 不抛异常即合法
except Exception as e:
raise ValueError(f"Cron 表达式无效: {e}")
# 生成 ID
schedule_id = "sched_" + str(uuid.uuid4())[:12]
# 获取目标名称
from . import target_service
target = target_service.get_target(target_id)
target_name = target.get("name") if target else target_id
schedule = {
"id": schedule_id,
"name": name,
"target_id": target_id,
"target_name": target_name,
"suite": suite,
"cron": cron_expr,
"enabled": True,
"last_run_at": None,
"last_run_status": None,
"last_report_id": None,
"next_run_at": _calc_next_run(cron_expr),
"created_at": _now(),
"created_by": created_by,
}
schedules = _load_schedules()
schedules.append(schedule)
_save_schedules(schedules)
logger.info("创建定时任务: %s (%s)", name, cron_expr)
return schedule
def update_schedule(schedule_id: str, data: dict) -> dict:
"""更新定时任务。"""
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
# 可更新字段
if "name" in data:
name = data["name"].strip()
if not name:
raise ValueError("任务名称不能为空")
schedules[i]["name"] = name
if "cron" in data:
cron_expr = data["cron"].strip()
if not cron_expr:
raise ValueError("Cron 表达式不能为空")
try:
from croniter import croniter
croniter(cron_expr)
except Exception as e:
raise ValueError(f"Cron 表达式无效: {e}")
schedules[i]["cron"] = cron_expr
schedules[i]["next_run_at"] = _calc_next_run(cron_expr)
if "suite" in data:
schedules[i]["suite"] = data["suite"]
_save_schedules(schedules)
logger.info("更新定时任务: %s", schedule_id)
return schedules[i]
raise ValueError("定时任务不存在")
def delete_schedule(schedule_id: str) -> bool:
"""删除定时任务。"""
schedules = _load_schedules()
new_schedules = [s for s in schedules if s.get("id") != schedule_id]
if len(new_schedules) == len(schedules):
return False
_save_schedules(new_schedules)
logger.info("删除定时任务: %s", schedule_id)
return True
def toggle_enabled(schedule_id: str, enabled: Optional[bool] = None) -> dict:
"""切换定时任务启用/禁用状态。"""
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
new_enabled = not s.get("enabled", True) if enabled is None else enabled
schedules[i]["enabled"] = new_enabled
if new_enabled:
schedules[i]["next_run_at"] = _calc_next_run(s["cron"])
_save_schedules(schedules)
logger.info("切换定时任务状态: %s -> %s", schedule_id, new_enabled)
return schedules[i]
raise ValueError("定时任务不存在")
def update_run_status(schedule_id: str, status: str, report_id: str, run_at: str) -> dict:
"""更新定时任务执行状态。"""
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
schedules[i]["last_run_at"] = run_at
schedules[i]["last_run_status"] = status
schedules[i]["last_report_id"] = report_id
schedules[i]["next_run_at"] = _calc_next_run(s["cron"])
_save_schedules(schedules)
return schedules[i]
raise ValueError("定时任务不存在")
# ============================================================
# APScheduler 集成
# ============================================================
def init_scheduler(app) -> None:
"""初始化 APScheduler,在 Flask app 创建后调用。"""
global _scheduler
with _scheduler_lock:
if _scheduler is not None:
return
try:
from apscheduler.schedulers.background import BackgroundScheduler
_scheduler = BackgroundScheduler()
_scheduler.start()
logger.info("APScheduler 已启动")
# 加载已启用的定时任务
_reload_jobs()
except ImportError:
logger.warning("APScheduler 未安装,定时任务功能不可用")
def _reload_jobs() -> None:
"""从 schedules.json 重新加载所有任务。"""
if _scheduler is None:
return
schedules = list_schedules()
for s in schedules:
if s.get("enabled"):
_add_job(s)
else:
_remove_job(s["id"])
def _add_job(sched: dict) -> None:
"""添加或更新单个 APScheduler job。"""
if _scheduler is None:
return
from apscheduler.triggers.cron import CronTrigger
job_id = f"schedule_{sched['id']}"
try:
trigger = CronTrigger.from_crontab(sched["cron"])
_scheduler.add_job(
func=_execute_scheduled_job,
trigger=trigger,
id=job_id,
args=[sched["id"]],
replace_existing=True,
)
logger.info("定时任务已注册: %s (%s)", sched["name"], sched["cron"])
except Exception as e:
logger.error("注册定时任务失败: %s - %s", sched["id"], e)
def _remove_job(schedule_id: str) -> None:
"""移除 APScheduler job。"""
if _scheduler is None:
return
job_id = f"schedule_{schedule_id}"
try:
_scheduler.remove_job(job_id)
logger.info("定时任务已移除: %s", schedule_id)
except Exception:
pass # job 可能不存在
def _execute_scheduled_job(schedule_id: str) -> None:
"""定时任务执行入口(在 APScheduler 线程中调用)。"""
logger.info("定时任务触发: %s", schedule_id)
sched = get_schedule(schedule_id)
if not sched or not sched.get("enabled"):
logger.warning("定时任务不存在或已禁用: %s", schedule_id)
return
try:
from . import runner_service
result = runner_service.run_inspection_sync(
sched["target_id"], sched["suite"]
)
update_run_status(
schedule_id,
status="success" if result.get("success") else "failed",
report_id=result.get("report_id", ""),
run_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
)
logger.info("定时任务执行完成: %s, report_id=%s", schedule_id, result.get("report_id"))
except Exception as e:
logger.error("定时任务执行异常: %s - %s", schedule_id, e)
update_run_status(
schedule_id,
status="error",
report_id="",
run_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
)
def reload_job(schedule_id: str) -> None:
"""重新加载单个定时任务(CRUD 后调用)。"""
if _scheduler is None:
return
sched = get_schedule(schedule_id)
if sched and sched.get("enabled"):
_add_job(sched)
else:
_remove_job(schedule_id)
def get_scheduler_status() -> dict:
"""获取调度器状态。"""
if _scheduler is None:
return {"running": False, "reason": "APScheduler 未初始化"}
return {
"running": _scheduler.running,
"job_count": len(_scheduler.get_jobs()),
}
......@@ -22,6 +22,7 @@ CONFIG_TEMPLATE = ASSETS_DIR / "config.sh.template"
DATA_DIR = MODULE_DIR / "data"
REPORTS_DIR = DATA_DIR / "reports"
TARGETS_FILE = DATA_DIR / "targets.json"
SCHEDULES_FILE = DATA_DIR / "schedules.json"
def ensure_dirs() -> None:
......
{% extends "service_monitor/base.html" %}
{% block title %}定时任务{% endblock %}
{% block extra_css %}
<style>
/* 表格 */
.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 1fr 1fr 1.2fr 1.5fr; gap: 12px; padding: 12px 18px; 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-row { border-bottom: 1px solid var(--gray-100); font-size: 14px; }
.table-row:last-child { border-bottom: none; }
.name { font-weight: 600; color: var(--gray-900); }
.cron { font-family: monospace; font-size: 13px; color: var(--gray-700); }
.status-badge { font-size: 12px; padding: 3px 10px; border-radius: 6px; font-weight: 600; }
.badge-enabled { background: #dcfce7; color: #15803d; }
.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; }
.btn-toggle { background: #fef3c7; color: #92400e; }
.btn-edit { background: var(--gray-100); color: var(--gray-700); }
.btn-del { background: #fee2e2; color: var(--red); }
/* 弹窗 */
.modal-mask { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 200; align-items: center; justify-content: center; padding: 20px; }
.modal-mask.show { display: flex; }
.modal { background: #fff; border-radius: 14px; padding: 28px; width: 100%; max-width: 480px; max-height: 90vh; overflow-y: auto; }
.modal h3 { font-size: 18px; color: var(--gray-900); margin-bottom: 18px; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--gray-700); margin-bottom: 5px; font-weight: 600; }
.form-group input, .form-group select { width: 100%; padding: 9px 12px; border: 1px solid var(--gray-200); border-radius: 8px; font-size: 14px; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: var(--primary); }
.form-hint { font-size: 12px; color: var(--gray-500); margin-top: 4px; }
.cron-presets { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.cron-preset { background: var(--gray-100); border: 1px solid var(--gray-200); padding: 5px 12px; border-radius: 6px; font-size: 12px; cursor: pointer; color: var(--gray-700); }
.cron-preset:hover { background: var(--gray-200); }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.btn-cancel { background: var(--gray-100); color: var(--gray-700); border: none; padding: 9px 18px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }
.btn-save { background: var(--primary); color: #fff; border: none; padding: 9px 18px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }
.btn-save:disabled { opacity: .6; cursor: not-allowed; }
@media screen and (max-width: 1024px) {
.table-head, .table-row { grid-template-columns: 1fr 1fr 1fr 1fr; }
.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; }
}
@media screen and (max-width: 768px) {
.table-head { display: none; }
.table-row { grid-template-columns: 1fr 1fr; gap: 6px; padding: 14px; }
}
</style>
{% endblock %}
{% block content %}
<div class="section-head">
<div class="section-title">⏰ 定时任务</div>
{% if is_admin %}
<button class="btn-add" onclick="openCreate()">+ 新增定时任务</button>
{% endif %}
</div>
{% if schedules %}
<div class="table">
<div class="table-head">
<span>任务名称</span>
<span>目标</span>
<span>套件</span>
<span>Cron</span>
<span>状态</span>
<span>下次执行</span>
<span>操作</span>
</div>
{% for s in schedules %}
<div class="table-row">
<span class="name">{{ s.name }}</span>
<span>{{ s.target_name or s.target_id }}</span>
<span>{{ '快速' if s.suite == 'quick' else '全量' }}</span>
<span class="cron">{{ s.cron }}</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>
<div class="actions">
{% if is_admin %}
<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-del" onclick="deleteSchedule('{{ s.id }}')">删除</button>
{% else %}
<span class="btn-sm" style="opacity:.5; cursor:default;">查看</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">📭 暂无定时任务,点击"新增定时任务"创建</div>
{% endif %}
<!-- 新增/编辑弹窗 -->
<div class="modal-mask" id="modal">
<div class="modal">
<h3 id="modal-title">新增定时任务</h3>
<div class="form-group">
<label>任务名称</label>
<input type="text" id="f-name" placeholder="如:每日全量巡检">
</div>
<div class="form-group">
<label>监测目标</label>
<select id="f-target">
{% for t in targets %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>巡检套件</label>
<select id="f-suite">
<option value="quick">快速巡检</option>
<option value="full">全量巡检</option>
</select>
</div>
<div class="form-group">
<label>Cron 表达式</label>
<input type="text" id="f-cron" placeholder="0 8 * * *">
<div class="form-hint">格式:分 时 日 月 周(如 0 8 * * * 表示每天 8:00)</div>
<div class="cron-presets">
<span class="cron-preset" onclick="setCron('0 8 * * *')">每天 08:00</span>
<span class="cron-preset" onclick="setCron('0 */6 * * *')">每 6 小时</span>
<span class="cron-preset" onclick="setCron('0 8 * * 1')">每周一 08:00</span>
<span class="cron-preset" onclick="setCron('0 12,18 * * *')">每天 12:00、18:00</span>
</div>
</div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeModal()">取消</button>
<button class="btn-save" id="btn-save" onclick="saveSchedule()">保存</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const TARGETS = {{ targets | tojson | safe }};
let editingId = null;
function openCreate() {
editingId = null;
document.getElementById('modal-title').textContent = '新增定时任务';
document.getElementById('f-name').value = '';
document.getElementById('f-target').selectedIndex = 0;
document.getElementById('f-suite').value = 'quick';
document.getElementById('f-cron').value = '0 8 * * *';
document.getElementById('modal').classList.add('show');
}
function openEdit(id) {
editingId = id;
document.getElementById('modal-title').textContent = '编辑定时任务';
// TODO: 填充现有数据
const row = event.target.closest('.table-row');
const name = row.querySelector('.name').textContent;
document.getElementById('f-name').value = name;
document.getElementById('modal').classList.add('show');
}
function closeModal() {
document.getElementById('modal').classList.remove('show');
}
function setCron(expr) {
document.getElementById('f-cron').value = expr;
}
async function saveSchedule() {
const data = {
name: document.getElementById('f-name').value.trim(),
target_id: document.getElementById('f-target').value,
suite: document.getElementById('f-suite').value,
cron: document.getElementById('f-cron').value.trim(),
};
if (!data.name) {
alert('请输入任务名称');
return;
}
if (!data.cron) {
alert('请输入 Cron 表达式');
return;
}
const url = editingId
? '/api/service-monitor/schedules/' + editingId
: '/api/service-monitor/schedules';
const method = editingId ? 'PUT' : 'POST';
try {
const r = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify(data),
});
const d = await r.json();
if (d.success) {
closeModal();
location.reload();
} else {
alert(d.error?.message || '保存失败');
}
} catch (e) {
alert('请求失败');
}
}
async function toggleSchedule(id) {
try {
const r = await fetch('/api/service-monitor/schedules/' + id + '/toggle', {
method: 'POST',
credentials: 'include',
});
const d = await r.json();
if (d.success) {
location.reload();
} else {
alert('操作失败');
}
} catch (e) {
alert('请求失败');
}
}
async function deleteSchedule(id) {
if (!confirm('确定删除该定时任务?')) return;
try {
const r = await fetch('/api/service-monitor/schedules/' + id, {
method: 'DELETE',
credentials: 'include',
});
const d = await r.json();
if (d.success) {
location.reload();
} else {
alert('删除失败');
}
} catch (e) {
alert('请求失败');
}
}
</script>
{% endblock %}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论