# 执行计划 — 服务监测模块（service-monitor）

> 版本：v1.0 ｜ 日期：2026-07-16
> 对应需求：`Docs/PRD_需求文档_服务监测模块.md`
> 参考实现：`临时目录/服务器监测/`（PowerShell + Bash 模块化架构 v4.0）
> 依赖：`paramiko`、`cryptography`（Fernet 加密）

---

## 0. 执行原则

- **完全隔离**：所有代码写在 `skill/code/web/service_monitor/` 子包内，不动 `routes/`、`services/`、`utils/`、`config.json`、`container.py`
- **对外仅两处接入**：`server.py` 注册 Blueprint + `modules.py`（已配好，无需改）
- **本期核心子集**：system(01/02/03/04) + Docker-basic + MySQL-basic + Redis-basic
- **只做监测不做修复**（修复按钮预留位）
- **角色控制**：操作类接口 `admin_required`，查看类接口 `login_required`

---

## 阶段一：脚手架与资产迁移

### 1.1 创建子包骨架

```
skill/code/web/service_monitor/
├── __init__.py              # 导出 bp
├── routes.py
├── services/
│   └── __init__.py
├── utils/
│   └── __init__.py
├── assets/
│   ├── config.sh.template
│   ├── common.sh
│   ├── system/
│   └── service/
├── data/                    # 运行时生成
│   ├── targets.json
│   └── reports/
└── tests/                   # 模块私有测试
    └── __init__.py
```

- `service_monitor/__init__.py`：`from .routes import bp`
- `service_monitor/data/.gitkeep`、`reports/.gitkeep` 保证目录入仓
- 模板目录 `templates/service_monitor/` 与平台模板体系一致（不在子包内，沿用 `templates/`）

### 1.2 搬迁 bash 检测模块（核心子集）

从 `临时目录/服务器监测/lib/` 拷贝以下文件到 `service_monitor/assets/`：

| 源文件 | 目标 | 处理 |
|--------|------|------|
| `lib/common.sh` | `assets/common.sh` | 原样 |
| `lib/config.sh` | `assets/config.sh.template` | 改造为模板（见 1.3） |
| `lib/system/01_system_basic.sh` | `assets/system/` | 原样 |
| `lib/system/02_cpu_check.sh` | `assets/system/` | 原样 |
| `lib/system/03_memory_check.sh` | `assets/system/` | 原样 |
| `lib/system/04_disk_check.sh` | `assets/system/` | 原样 |
| `lib/service/20_docker_basic.sh` | `assets/service/` | 原样 |
| `lib/service/22_mysql_basic.sh` | `assets/service/` | 原样 |
| `lib/service/24_redis_basic.sh` | `assets/service/` | 原样 |

### 1.3 改造 `config.sh.template`（关键：支持模糊匹配）

原 `config.sh` 把容器名写死（`umysql`/`uredis`）。改造为**模式匹配**：

```bash
# 容器名匹配模式（模糊匹配，docker ps | grep -iE 应用）
declare -A CONTAINER_PATTERNS
CONTAINER_PATTERNS[mysql]="mysql"          # 匹配含 mysql 的容器名
CONTAINER_PATTERNS[redis]="redis"
CONTAINER_PATTERNS[emqx]="emqx"
CONTAINER_PATTERNS[java]="java|api"
CONTAINER_PATTERNS[nginx]="nginx"
CONTAINER_PATTERNS[nacos]="nacos"
CONTAINER_PATTERNS[python]="python"

# 凭据占位符（运行时由 executor 注入实际值）
MYSQL_PASSWORD="__MYSQL_PASSWORD__"
REDIS_PASSWORD="__REDIS_PASSWORD__"

# 阈值（可被目标级配置覆盖）
CPU_WARNING=${CPU_WARNING:-85}
CPU_CRITICAL=${CPU_CRITICAL:-100}
MEMORY_WARNING=${MEMORY_WARNING:-85}
MEMORY_CRITICAL=${MEMORY_CRITICAL:-95}
DISK_WARNING=${DISK_WARNING:-90}
DISK_CRITICAL=${DISK_CRITICAL:-95}

# 运行时由 executor 注入的辅助函数：resolve_container <key>
# 返回第一个匹配的容器名，未匹配返回空
resolve_container() {
    local key=$1
    local pattern="${CONTAINER_PATTERNS[$key]}"
    [ -z "$pattern" ] && return 1
    docker ps --format '{{.Names}}' 2>/dev/null | grep -iE "$pattern" | head -1
}
```

> 说明：参考脚本里 `check_container`/`get_container_ip` 都是精确名匹配，搬迁后凡是调容器名的检测模块需改用 `resolve_container`。本期核心子集里 docker/mysql/redis basic 会涉及，改造时逐个处理。

---

## 阶段二：工具层（utils/）

### 2.1 `utils/paths.py`（模块私有，不与全局 paths.py 冲突）

```python
# service_monitor/utils/paths.py
MODULE_DIR = Path(__file__).resolve().parent.parent          # service_monitor/
ASSETS_DIR = MODULE_DIR / "assets"
DATA_DIR = MODULE_DIR / "data"
REPORTS_DIR = DATA_DIR / "reports"
TARGETS_FILE = DATA_DIR / "targets.json"
CONFIG_TEMPLATE = ASSETS_DIR / "config.sh.template"
```

### 2.2 `utils/crypto.py` — 凭据加密

```python
# Fernet 对称加密
# 密钥来源：env MONITOR_ENC_KEY > 派生自 SECRET_KEY
def get_fernet() -> Fernet
def encrypt_password(plain: str) -> str        # 存入 targets.json
def decrypt_password(token: str) -> str         # 执行时解出
```

- 禁止明文落盘；`targets.json` 里 `password` 字段存 `token`
- 依赖 `cryptography`（需加入部署依赖）

### 2.3 `utils/check_modules.py` — 检测模块清单

```python
@dataclass
class CheckModule:
    id: str               # "01_system_basic"
    name: str             # "系统基础信息"
    category: str         # "system" | "service"
    suite: set            # {"quick", "full"} 哪个套件包含
    filename: str          # "01_system_basic.sh"

MODULES = [
    CheckModule("01_system_basic", "系统基础信息", "system", {"quick","full"}, ...),
    CheckModule("02_cpu_check", "CPU检测", "system", {"quick","full"}, ...),
    CheckModule("03_memory_check", "内存检测", "system", {"quick","full"}, ...),
    CheckModule("04_disk_check", "磁盘检测", "system", {"quick","full"}, ...),
    CheckModule("20_docker_basic", "Docker基础", "service", {"quick","full"}, ...),
    CheckModule("22_mysql_basic", "MySQL基础", "service", {"full"}, ...),
    CheckModule("24_redis_basic", "Redis基础", "service", {"full"}, ...),
]

def get_suite(suite: str) -> list[CheckModule]      # "quick" → 核心子集
def render_config(target: Target) -> str            # 用模板 + target 配置渲染 config.sh
```

### 2.4 `utils/parser.py` — 输出解析（移植 Parse-ModuleResult）

移植 PowerShell 的 `Parse-ModuleResult` 逻辑为 Python：

```python
def parse_module_output(raw: str, module_id: str) -> list[CheckItem]:
    """解析 bash 模块的 KEY:VALUE 输出，过滤脏行"""
    # 过滤：空行、注释、[INFO] 日志行、grep 警告、bash 报错行、中文冒号行
    # 正则匹配 ^([A-Z_][A-Z0-9_]*)\s*:\s*(.+)$
    # 返回 [{key, value, module, category}]

def get_display_name(key: str) -> str      # 移植 displayNames 映射表（按需迁移子集）
def get_threshold(key: str) -> str         # 移植 thresholds 表（按需迁移子集）
def get_status(key, value, threshold) -> str   # 正常/警告/严重
```

> 移植范围：只迁移本期核心子集涉及的 KEY（CPU_*/MEMORY_*/DISK_*/DOCKER_*/MYSQL_*/REDIS_*），其余后续补。

### 2.5 `utils/executor.py` — 执行器抽象

```python
class BaseExecutor(ABC):
    @abstractmethod
    def test_connection(self) -> bool
    @abstractmethod
    def upload_modules(self) -> bool
    @abstractmethod
    def run_module(self, module: CheckModule, config_rendered: str) -> str
    @abstractmethod
    def cleanup(self)

class LocalExecutor(BaseExecutor):
    """本机：直接 subprocess 调 bash"""
    # 无需上传，模块文件已在 assets/
    # 执行：bash -c 'cd <tmp> && source config.sh && source common.sh && system/01.sh'

class SSHExecutor(BaseExecutor):
    """远程：paramiko"""
    def __init__(self, target: Target)
    # test_connection: ssh exec echo OK
    # upload_modules: SFTP 上传 assets/ → /tmp/check_modules_{run_id}/
    # run_module: ssh exec bash -c '...' → 收集 stdout
    # cleanup: rm -rf /tmp/check_modules_{run_id}/
```

- 防注入：host/user 用正则白名单校验（`^[\w.\-]+$`）
- 超时：每个模块 90s，整体可取消

---

## 阶段三：业务层（services/）

### 3.1 `services/target_service.py` — 监测目标 CRUD

```python
def list_targets(role: str) -> list     # 普通用户只返回本地目标元信息，管理员返回全部
def get_target(tid: str) -> Target
def create_target(data: dict) -> Target     # admin
def update_target(tid, data) -> Target      # admin
def delete_target(tid) -> bool              # admin，本地目标不可删
def test_connection(data: dict) -> bool      # admin，保存前测连通
```

`Target` 结构：
```json
{
  "id": "uuid",
  "name": "生产数据库服务器",
  "type": "local|remote",
  "host": "192.168.x.x",     // local 时为空
  "port": 22,
  "username": "root",
  "password_enc": "<fernet token>",
  "container_overrides": {     // 可选，覆盖默认模糊模式
    "mysql": "umysql-prod"
  },
  "credential_overrides": {    // 可选，覆盖默认密码占位符
    "mysql_password_enc": "<token>"
  },
  "thresholds": {              // 可选，目标级阈值覆盖
    "cpu_warning": 90
  },
  "created_at": "...",
  "updated_at": "..."
}
```

### 3.2 `services/runner_service.py` — 巡检编排

```python
def run_inspection(target_id: str, suite: str, run_id: str) -> Iterator[dict]:
    """生成器/SSE 事件流
    events: progress(module) / module_done(result) / finished(report_id) / error(msg)
    """
    target = get_target(target_id)
    executor = LocalExecutor() if target.type=='local' else SSHExecutor(target)
    modules = get_suite(suite)
    config_rendered = render_config(target)
    for m in modules:
        yield {"event":"progress","module":m.name}
        raw = executor.run_module(m, config_rendered)
        items = parse_module_output(raw, m.id)
        yield {"event":"module_done","module":m.name,"items":items,"summary":summarize(items)}
    report_id = report_service.save(target, suite, collected)
    yield {"event":"finished","report_id":report_id}

def cancel_run(run_id: str) -> bool
```

- 后台线程执行，SSE 推进度
- 报告落 `data/reports/{report_id}.json`

### 3.3 `services/report_service.py` — 报告管理

```python
def save(target, suite, collected) -> str       # 写 reports/{id}.json
def get_report(report_id) -> dict
def list_reports(target_id=None, limit=50) -> list
def export_markdown(report_id) -> str            # 移植参考脚本的 MD 报告生成
def export_json(report_id) -> dict
def cleanup_expired(retention_days=14)          # 清理过期报告
```

报告结构：
```json
{
  "id": "...",
  "target_id": "...", "target_name": "...", "target_type": "...",
  "suite": "quick", "started_at": "...", "finished_at": "...",
  "summary": {"normal": 12, "warning": 3, "critical": 1, "total": 16},
  "modules": [
    {"id":"02_cpu_check","name":"CPU检测","category":"system",
     "items":[{"key":"CPU_USAGE","name":"CPU使用率","value":"85.3%","threshold":">85%","status":"警告"}]}
  ]
}
```

---

## 阶段四：路由层（routes.py）

所有 API 走 `/api/service-monitor/*`，页面走 `/service-monitor/*`。

```python
bp = Blueprint('service-monitor', __name__)

# 页面
@bp.route('/service-monitor')
def page_index()                     # 监测主页（目标列表 + 历史报告）

@bp.route('/service-monitor/targets')
def page_targets()                   # 目标管理（admin）

@bp.route('/service-monitor/run/<target_id>')
def page_run(target_id)              # 巡检执行页（admin）

@bp.route('/service-monitor/report/<report_id>')
def page_report(report_id)           # 报告详情（所有登录用户）

# API - 目标管理（admin_required）
GET    /api/service-monitor/targets
POST   /api/service-monitor/targets              # 新增
PUT    /api/service-monitor/targets/<tid>         # 编辑
DELETE /api/service-monitor/targets/<tid>         # 删除
POST   /api/service-monitor/targets/test          # 连通性测试（不保存）

# API - 巡检（admin_required）
POST   /api/service-monitor/run          # body: {target_id, suite} → 启动，返回 run_id
GET    /api/service-monitor/run/<run_id>/stream   # SSE 进度流
POST   /api/service-monitor/run/<run_id>/cancel

# API - 报告（login_required）
GET    /api/service-monitor/reports              # 列表
GET    /api/service-monitor/reports/<rid>        # 详情
GET    /api/service-monitor/reports/<rid>/export?format=md|json
```

**接入点**（仅此一处改全局文件）：
```python
# server.py create_app() 内追加
from service_monitor import bp as service_monitor_bp
app.register_blueprint(service_monitor_bp)
```

> 注意：`server.py` 当前已有一行 `from routes.service_monitor import bp as service_monitor_bp`（占位旧路由）。需替换为 `from service_monitor import bp as service_monitor_bp`，并删除旧的 `routes/service_monitor.py` 占位文件 + `templates/service_monitor.html` 占位模板，避免命名冲突。

---

## 阶段五：前端模板

复用平台统一导航栏风格（参考 `templates/service_monitor.html` 的 navbar），新建：

| 模板 | 功能 |
|------|------|
| `templates/service_monitor/index.html` | 监测主页：目标概览 + 最近报告列表 |
| `templates/service_monitor/targets.html` | 目标 CRUD（admin 可见操作按钮，普通用户只读） |
| `templates/service_monitor/run.html` | 选套件→执行→SSE 进度条→完成跳报告 |
| `templates/service_monitor/report.html` | 报告详情：按模块分组表格 + 状态色块 + 异常置顶 + 导出按钮 + 修复预留位 |

- 响应式适配（沿用 P2-2 的断点规范）
- SSE 客户端：`EventSource('/api/service-monitor/run/<id>/stream')`
- 角色控制：模板内据 `user.role` 显隐操作按钮

---

## 阶段六：测试

### 6.1 模块私有测试 `service_monitor/tests/`

| 测试文件 | 覆盖 |
|---------|------|
| `test_parser.py` | parser 过滤脏行 + KEY:VALUE 解析（mock 原始输出） |
| `test_thresholds.py` | 状态判定：数值/百分比/布尔各类阈值规则 |
| `test_crypto.py` | 加密-解密往返 |
| `test_check_modules.py` | render_config 占位符替换 + suite 过滤 |
| `test_executor.py` | LocalExecutor 本地执行真模块（CPU 模块）；SSHExecutor 用 mock paramiko |
| `test_target_service.py` | CRUD + 角色可见性 |
| `test_report_service.py` | 报告存储/读取/过期清理/MD 导出 |

- `conftest.py` 注入临时 data 目录，避免污染真实数据
- 不依赖网络/真实 SSH（SSHExecutor 全 mock）

### 6.2 不影响现有 145 用例

- 现有 `tests/` 不动；新测试放 `service_monitor/tests/`，独立收集

---

## 阶段七：部署与依赖

### 7.1 依赖
- `paramiko`、`cryptography` 加入 `requirements`（若无则新建 `skill/code/requirements.txt`）
- 部署容器需 `pip install paramiko cryptography`

### 7.2 上传清单（`deploy/upload_to_server.py`）
新增 `DIRS_TO_UPLOAD` 条目：
- `skill/code/web/service_monitor`（整个子包）
- `templates/service_monitor`（新建目录）

### 7.3 环境变量（`.env.example`）
新增：
```
MONITOR_ENC_KEY=         # 可选，不设则派生自 SECRET_KEY
```

### 7.4 验证
- 单元测试全绿
- `verify_deployment.py` 增补服务监测端点检查（可选）
- 手动验证：本地目标快速巡检 → 报告 → 导出 MD

---

## 实施顺序与里程碑

| 步骤 | 内容 | 产出 |
|------|------|------|
| 1 | 子包骨架 + 资产搬迁 + config 模板改造 | 可 import 的空 bp |
| 2 | utils 层（paths/crypto/check_modules/parser/executor） | 工具可单测 |
| 3 | services 层（target/runner/report） | 业务可跑 |
| 4 | routes 层 + server.py 接入 + 删旧占位 | API 可调 |
| 5 | 前端模板 | 页面可用 |
| 6 | 测试 | 全绿 |
| 7 | 部署清单 + 依赖 + 验证 | 上线 5.60 |

每步完成后可独立提交，便于回溯。

---

## 风险与避坑

1. **config.sh 模糊匹配**：原脚本是精确容器名，改 `resolve_container` 后要逐个核对 docker/mysql/redis basic 模块内的容器引用点，别漏改。
2. **编码**：bash 模块输出含中文，subprocess/SSH 读取统一按 UTF-8 解码，Windows GBK 不影响（运行在 Linux 容器）。
3. **SSHExecutor 超时**：单模块 90s，全量巡检可能数分钟，必须 SSE + 可取消，避免前端卡死。
4. **Blueprint 端点名**：端点是 `service-monitor.xxx`（注意横线），`url_for` 要用全名。
5. **占位文件冲突**：旧 `routes/service_monitor.py` + `templates/service_monitor.html` 必须删除，否则与新子包 bp 重名冲突。
6. **修复预留位**：报告页异常项放「修复」按钮，点击返回「修复能力开发中」，不接真实逻辑。
