# 执行计划 — 左侧菜单布局改造与定时自动巡检

> 版本：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` |
