# 设计 — 服务器实时监测槽（4 台自选 · 宝塔式）

**日期**：2026-06-12  
**状态**：待实施  
**关联讨论**：非全舰队；SSH/Redis 探针；8h 自动下架；独立历史页

## 背景与目标

运维需要在 **服务器页** 对 **手动选定的少数子机**（最多 **4 台**）做 **近实时** 资源监视（CPU、内存、磁盘、负载、网络/磁盘 IO、进程 Top），体验接近 **宝塔面板监控**：数字与曲线 **约 5 秒刷新**，而非现有 10 分钟历史粒度。

**不在范围**：全舰队 5s 探针；改造现有 Schedules（推送/脚本）页；无 SSH 且无 Agent 的机器无法探针（需明确失败态）。

## 已确认产品决策

| 项 | 决定 |
|----|------|
| 同时监测上限 | **4 台**（固定 4 槽） |
| 选机方式 | **服务器列表** 每行 **「+」** 加入监测；槽位区也可点「+」选机 |
| 范围 | **仅 Pin 的子机** 高频采样；未 Pin **零额外 SSH/探针** |
| 生命周期 | 添加后 **8 小时** 自动下架；用户可手动删除；重复添加同一台 **重置 8h** |
| 实时感 | 目标 **5s** 刷新（探针 + WebSocket 推送） |
| 数据源 | **有 Agent 且 Redis 新鲜** → 读 Redis；否则 **SSH psutil 脚本** |
| 历史 | Pin 期间 **每次探针均落库**（成功/失败皆有记录）；**独立页面** 查趋势 + **探针记录**；7 天 purge |
| 隔离 | **按管理员** 4 槽（独立表 + `admin_id`） |
| **探针记录** | **强制**：每 5s 一轮必有 DB 行；失败写 `probe_status` + `error`，**禁止只打日志不落库** |
| **指标范围** | **每轮 5s 全量刷新**：CPU、内存、**磁盘使用率**、负载、**磁盘读写**、**网络入口/出口（带宽）**、进程 Top（10s）；见下节 |

## 指标清单（每轮探针必采 · 与宝塔对齐）

> **原则**：Pin 期间 **所有下列指标同一 5s 周期采集、落库、推 WS、刷新 UI**；不得把磁盘/网络放到「二期」或降频。

| 类别 | 字段 | 展示（槽位卡） | 说明 |
|------|------|----------------|------|
| CPU | `cpu_pct` | 圆环 + 曲线 | % |
| 内存 | `mem_pct` | 圆环 + 曲线 | % |
| **磁盘空间** | `disk_pct` | 圆环 + 曲线 | 根分区 `/` 使用率 %（与 Agent/SSH 一致） |
| 负载 | `load_1/5/15` | 数字 | load average |
| **磁盘 IO** | `disk_read_bytes`, `disk_write_bytes` | **读/写速率** | 累计字节；API/前端用相邻两点差算 **MB/s** |
| **网络出入口** | `net_bytes_recv`, `net_bytes_sent` | **下行↓ / 上行↑ 速率** | recv=入口/下载，sent=出口/上传；差算 **Mbps 或 MB/s** |
| 进程 | `processes_json` | Top5 表 | 每 **10s** 刷新内容，仍每 5s 写探针行 |

**速率计算**（中央统一，避免前端各算各的）：

```
rate_sent = (sent[t] - sent[t-1]) / (ts[t] - ts[t-1])   → 出口带宽
rate_recv = (recv[t] - recv[t-1]) / (ts[t] - ts[t-1])   → 入口带宽
disk_read_rate / disk_write_rate 同理
```

首点无上一采样时速率显示 `—`；WS 推送 payload 含 **已算好的** `net_up_bps`, `net_down_bps`, `disk_read_bps`, `disk_write_bps` 供卡片直接用。

**数据源**：

| 路径 | CPU/MEM/DISK% | 网络 cumulative | 磁盘 IO cumulative |
|------|---------------|-----------------|---------------------|
| Redis（Agent 心跳） | ✅ `system_info` 已有 | ⚠ 心跳 **默认无** → Pin 机走 SSH 补采 **或** Agent 小改带上 `net_io`/`disk_io`（推荐 **M1：SSH 脚本一次拿全**） |
| SSH 探针 | ✅ psutil 一次 JSON | ✅ `psutil.net_io_counters()` | ✅ `psutil.disk_io_counters()` |

Pin 且 Redis 仅含 cpu/mem/disk% 时：**同轮 SSH 补采 IO/网络**，合并为一行 `watch_probe_records`（`source=redis+ssh` 或主来源记 `ssh`）。

**UI（槽位卡 · 宝塔式）**：

```
CPU 78%  MEM 62%  DISK 41%
↑ 12.3 MB/s  ↓ 8.6 MB/s     ← 网络出入口（实时）
读 2.1 MB/s  写 0.4 MB/s    ← 磁盘 IO（可选一行，M1 即显示）
[15min 折线：CPU / MEM / DISK / 下行带宽 可切换图例]
```

## 方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| A. 仅复用 `server_metric_samples`（10min） | 无新表 | 无法宝塔式实时 |
| B. 全舰队 Agent 5s 心跳 | 真实时 | 2000+ 台不可接受 |
| C. **自选 4 台 + 5s 探针 + WS（选定）** | 负载可控；SSH 覆盖无 Agent；体验接近宝塔 | 需新表、worker、WS 通道 |
| D. 前端 5s 轮询 HTTP | 实现简单 | 延迟与连接数差于 WS；仍要后台探针 |

**选定 C**：后台 `watch_probe` 仅对 Pin 列表采样；Redis 滑动窗口 + MySQL 持久化；WebSocket 推送到浏览器。

## 信息架构

### 服务器页布局

```
StatCardsRow（现有）
WatchSlotRow（新增，4 格）
  空槽：虚线卡片「+ 添加监测」
  有槽：主机名、CPU/MEM/**DISK** 三环、15min 滚动折线（**CPU+内存+磁盘** 可切换）、网络/磁盘 IO 速率、剩余时间、删除
服务器列表（现有）
  操作列新增：mdi-plus 图标按钮「加入实时监测」
  已在监测：显示已占用槽位或 disabled + tooltip「已在监测 · 槽 2」
```

### 列表「+」交互

1. 点击 **+** → 若未满 4 槽：直接占用 **第一个空槽**（或弹出选槽 1～4，**默认最小空槽号**）。
2. 若已满 4 槽：**对话框**「监测已满，请选择要替换的槽位」→ 选槽 → 替换。
3. 已在监测的 server：**+ 变为 mdi-check 或隐藏**，tooltip 提示槽位与剩余时间。
4. 无 SSH 凭据且无 Agent：+ 可点但 POST 返回 422，snackbar 说明原因（不静默失败）。

### 独立历史页

- 路由：`#/watch-metrics`（侧栏「监测历史」或从 WatchSlotRow「查看历史 →」进入）
- **Tab A — 资源趋势**：选 1～4 台、时间范围 1h/6h/24h/7d；曲线含 **CPU / 内存 / 磁盘使用率 / 网络↑↓ / 磁盘读写的速率**（由相邻探针差分计算）；离线 shading；导出 CSV
- **Tab B — 探针记录**：按服务器/时间/状态筛选的 **只读表格**（见下文「探针记录」）；支持导出 CSV
- 与 Dashboard 舰队均值页区分：本页 **单机/自选多机** + **Pin 期间细粒度 + 全量探针日志**

## 探针记录（强制）

> **原则**：监测槽的价值不仅在实时曲线，还在 **可回溯的探针执行记录**——SSH 超时、凭据失败、Redis 过期等必须可查，不得仅写应用日志或静默丢弃。

### 记什么

每一轮 `watch_probe` 对每台 **Pin 中的 server**（去重后）**必须 INSERT 一行**，无论成功或失败：

| 字段语义 | 成功 | 失败示例 |
|----------|------|----------|
| `probe_status` | `ok` | `ssh_timeout` / `ssh_auth` / `no_cred` / `redis_stale` / `offline` / `parse_error` |
| 指标列 | 有值 | **NULL**（禁止用 0 冒充） |
| `error` | NULL | 人类可读摘要（≤255 字） |
| `duration_ms` | 探针耗时 | 同上 |
| `source` | `redis` / `ssh` | 尝试的数据源 |

### 与会话关联

每次用户点列表 **+** 加入监测，创建一条 **`watch_pin_sessions`**（8h 监测会话）；探针记录通过 `session_id` 归属到「哪一段 Pin 期间」。

Pin 到期或手动删除 → 更新 `ended_at` + `end_reason`（`expired` / `manual` / `replaced`）；**已写入的探针记录不删**，保留至 purge 策略。

### UI 在哪看

1. **监测历史页 · 探针记录 Tab** — 主入口，可跨会话、跨机器查
2. **监测槽卡片 · 「记录」链接** — 跳转并预填当前 server + 当前 session 时间范围
3. **服务器列表展开详情（可选 M2）** — 若该台正在 Pin，展示最近 20 条探针摘要

### 运维用途

- 排查「曲线断档」：按 `probe_status=ssh_timeout` 过滤
- 对比 Redis vs SSH：看 `source` 列
- 审计：谁在何时 Pin 了哪台（`watch_pin_sessions` + audit log）

## 数据模型

### `watch_pin_sessions`

```sql
watch_pin_sessions (
  id            INT PK AUTO_INCREMENT,
  admin_id      INT NOT NULL,
  server_id     INT NOT NULL,
  slot_index    TINYINT NOT NULL,
  started_at    DATETIME NOT NULL,
  ended_at      DATETIME NULL,
  end_reason    VARCHAR(16) NULL,   -- expired | manual | replaced
  INDEX (admin_id, started_at),
  INDEX (server_id, started_at)
)
```

`watch_pins` 当前活跃 Pin 可 FK 指向 `session_id`；到期删 pin 行，**session 保留**。

### `watch_pins`

```sql
watch_pins (
  id            INT PK AUTO_INCREMENT,
  admin_id      INT NOT NULL FK admins,
  session_id    INT NOT NULL FK watch_pin_sessions,
  slot_index    TINYINT NOT NULL,          -- 0..3
  server_id     INT NOT NULL FK servers,
  pinned_at     DATETIME NOT NULL,         -- UTC naive
  expires_at    DATETIME NOT NULL,         -- pinned_at + 8h
  UNIQUE (admin_id, slot_index),
  UNIQUE (admin_id, server_id),
  INDEX (expires_at)
)
```

### `watch_probe_records`

> 表名实现可用 `watch_probe_records`；与早期草案 `watch_metric_samples` 合并为 **同一张表**（指标 + 探针状态一体），避免双写。

```sql
watch_probe_records (
  id            BIGINT PK AUTO_INCREMENT,
  session_id    INT NOT NULL FK watch_pin_sessions,
  server_id     INT NOT NULL,
  admin_id      INT NOT NULL,              -- Pin 发起人（查询隔离）
  recorded_at   DATETIME NOT NULL,
  source        VARCHAR(16) NOT NULL,      -- redis | ssh
  probe_status  VARCHAR(24) NOT NULL,      -- ok | ssh_timeout | ...
  duration_ms   INT NOT NULL,
  is_online     BOOLEAN NOT NULL,
  cpu_pct       SMALLINT NULL,
  mem_pct       SMALLINT NULL,
  disk_pct      SMALLINT NULL,             -- 根分区 / 使用率 %
  disk_mount    VARCHAR(255) NULL DEFAULT '/',  -- 采样分区（M1 固定 /）
  load_1        FLOAT NULL,
  load_5        FLOAT NULL,
  load_15       FLOAT NULL,
  net_bytes_sent   BIGINT NULL,            -- 累计：网络出口（上传）
  net_bytes_recv   BIGINT NULL,            -- 累计：网络入口（下载）
  disk_read_bytes  BIGINT NULL,
  disk_write_bytes BIGINT NULL,
  net_up_bps       BIGINT NULL,            -- 出口带宽 bytes/s（写入时算好）
  net_down_bps     BIGINT NULL,            -- 入口带宽 bytes/s
  disk_read_bps    BIGINT NULL,
  disk_write_bps   BIGINT NULL,
  processes_json   JSON NULL,              -- top 5；10s 更新内容
  error         VARCHAR(255) NULL,
  INDEX (session_id, recorded_at),
  INDEX (server_id, recorded_at),
  INDEX (admin_id, recorded_at),
  INDEX (probe_status, recorded_at)
)
```

**写入纪律**：

- `watch_probe` **每 5s 每台 Pin 机至少 1 INSERT**；进程 Top 可每 2 轮更新 `processes_json`（仍写行，仅进程字段可空）。
- 探针异常：`probe_status != ok` 时指标列全 NULL + `error` 必填。
- **保留 7 天**；`watch_records_purge` 每日 batch DELETE。

**估算**：1 台 Pin 8h ≈ 5760 行（5s）；4 台 ≈ 2.3 万行/8h，7 天可接受。

### Redis（实时滑动窗口）

- Key：`watch:live:{server_id}` — **5s 一点**，保留 **30 分钟**；每点 JSON **含完整指标**：

```json
{
  "ts": "2026-06-12T14:05:05Z",
  "cpu_pct": 12,
  "mem_pct": 45,
  "disk_pct": 61,
  "load_1": 0.8,
  "net_bytes_sent": 102400,
  "net_bytes_recv": 204800,
  "net_up_bps": 1310720,
  "net_down_bps": 917504,
  "disk_read_bytes": 4096,
  "disk_write_bytes": 8192,
  "disk_read_bps": 2202009,
  "disk_write_bps": 419430,
  "probe_status": "ok",
  "source": "ssh"
}
```

- Key：`watch:proc:{server_id}` — 进程 JSON，TTL 60s，**10s 更新**
- Pub/Sub：`nexus:watch` — payload 与上同结构 + `server_id`, `admin_ids[]`

## 后台任务

| 任务 | 间隔 | 职责 |
|------|------|------|
| `watch_probe` | **5s** | 读 Pin 并集；Redis 或 SSH；**每轮每台必 INSERT `watch_probe_records`**；写 Redis 窗口；Pub/Sub WS |
| `watch_pin_expiry` | 60s | 过期 pin → 更新 session.`ended_at`；删 pin 行 |
| `watch_records_purge` | 每日 | DELETE `watch_probe_records` / 旧 session 元数据 > 7d |

**Primary worker only**（与 `heartbeat_flush` 一致）。

### SSH 探针脚本（单次 JSON，指标一次带回）

在 `remote_probe` 基础上扩展，**一次 exec** 输出：

```json
{
  "cpu_usage": 12.5,
  "mem_usage": 45.2,
  "disk_usage": 61.0,
  "disk_mount": "/",
  "load_1": 0.82,
  "load_5": 0.91,
  "load_15": 1.05,
  "net_bytes_sent": 123456789,
  "net_bytes_recv": 987654321,
  "disk_read_bytes": 1234567890,
  "disk_write_bytes": 9876543210,
  "top_processes": [{"name":"nginx","pid":1234,"cpu_pct":2.1,"mem_pct":1.2}]
}
```

超时 **8s**；失败仍 INSERT；**禁止**只采 CPU/内存而跳过磁盘与网络出入口。

## API

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/watch/pins` | 当前管理员 4 槽 + 最新实时指标 + 剩余秒数 + `session_id` |
| POST | `/api/watch/pins` | `{ "server_id": 1, "slot_index"?: 0 }`；创建 session；满则 409 |
| PUT | `/api/watch/pins/{slot}` | 替换槽内 server_id（旧 session `end_reason=replaced`） |
| DELETE | `/api/watch/pins/{slot}` | 手动移除（session `end_reason=manual`） |
| GET | `/api/watch/live?server_ids=1,2` | 读 Redis 窗口（兜底轮询） |
| GET | `/api/watch/metrics?server_ids=&hours=24` | 从 `watch_probe_records` 聚合曲线（仅 `probe_status=ok`） |
| GET | `/api/watch/probe-records` | **探针记录分页**；query: `server_id`, `session_id`, `hours`, `probe_status`, `page` |
| GET | `/api/watch/sessions?hours=168` | Pin 会话列表（起止时间、机器、结束原因、探针成功/失败计数） |

**`GET /api/watch/probe-records` 响应示例**：

```json
{
  "items": [
    {
      "id": 9912,
      "recorded_at": "2026-06-12 14:05:05",
      "server_id": 42,
      "server_name": "web-01",
      "source": "ssh",
      "probe_status": "ssh_timeout",
      "duration_ms": 8001,
      "error": "SSH exec timed out after 8s",
      "cpu_pct": null,
      "disk_pct": null,
      "net_up_bps": null,
      "net_down_bps": null
    }
  ],
  "total": 5760,
  "page": 1,
  "per_page": 50
}
```

鉴权：`Depends(get_current_admin)`；仅可查 **本人 `admin_id`** 的 session/记录；CUD Pin 写 audit log（`watch_pin_add/remove/replace`）。

## WebSocket

- 端点：复用 `/ws/alerts` 扩展 `type: watch_metrics` **或** 新 `/ws/watch`（推荐 **新 channel**，避免告警拥塞）
- 订阅：连接后带 JWT；服务端维护 `admin_id → socket`；仅推送 **该 admin 当前 Pin 的 server_id** 更新
- 前端：Servers 页 mount 时 connect；unmount disconnect；收到包更新 WatchSlotRow 与 ECharts append

## 前端组件

| 组件/页 | 职责 |
|---------|------|
| `WatchSlotRow.vue` | StatCardsRow 下 4 卡；gauge + 15min 折线；倒计时 |
| `WatchSlotCard.vue` | 单槽 UI：三环 + **↑出口 ↓入口** + 磁盘读写速率 |
| `useWatchPins.ts` | Pin CRUD、WS、倒计时 |
| `WatchMetricsPage.vue` | 历史趋势 + **探针记录 Tab**（`v-data-table-server`） |
| `WatchProbeRecordsTable.vue` | 状态、source、CPU/MEM/**DISK**、**网络↑↓**、耗时、error |
| `ServersPage.vue` | 操作列 `mdi-plus`；接 POST pin |

**图表**：ECharts（与 `FleetTrendEChart` 同栈）；实时 `appendData` + 窗口上限 360 点。

## 安全与性能

- **硬上限**：每 admin 4 pin；探针 server 去重（多 admin pin 同一台 → 探针 **1 次**，WS **分 admin 推**）
- **SSH**：仅 Pin 集合；连接池复用；并发 ≤4（可配置）
- **无静默吞错**：SSH 失败、无凭据、超时 → 卡槽红态 + **探针记录行** + 最后 error 文案
- **进程 JSON**：命令行截断 80 字符，不落盘敏感 env
- **估算负载**：100 admin × 4 pin 极端 = 400 台（理论）；实际运维 **个位数 admin × 4** → 探针 QPS ≈ 0.8/s 可接受

## 与现有能力关系

| 现有 | 关系 |
|------|------|
| Agent 60s 心跳 + Redis | Pin 且 Redis 新鲜 → **优先**，减 SSH |
| `server_metric_samples` 10min | **保留**；与 watch 探针记录 **独立**；长历史兜底 |
| 列表展开 `ServerMetricSparklines` | **保留**；与监测槽互补 |
| Schedules | **不扩展** schedule_type |

## 验收标准

1. 服务器列表每行有 **+**，点击后 **5s 内** CPU/内存/磁盘% 与 **网络出入口速率** 连续更新。
2. 满 4 槽再点 **+** 出现替换对话框；手动删除后槽变空。
3. **8h** 到期自动消失（误差 ≤ 2min）；再次 **+** 同一台重置 8h。
4. 未 Pin 的子机：抓包/日志 **无** 额外 SSH 探针。
5. `#/watch-metrics` **探针记录 Tab** 可见每次 5s 探针（含失败）；按 session 筛选与 Pin 时段一致。
6. SSH 失败：卡槽红态 + **DB 有 `probe_status=ssh_*` 行**；**不**显示 0% 冒充正常。
7. 模拟 SSH 超时单测：断言 **仍有 INSERT** 且 `cpu_pct IS NULL`。
8. 探针记录与趋势页均含 **disk_pct、net_up_bps、net_down_bps** 列/序列；相邻 5s 点带宽差分正确。
9. `local_verify` + 单测（含速率差分、全指标 SSH mock）+ type-check。

## 实施分期

| 阶段 | 内容 |
|------|------|
| **M1** | 表 + 探针 **全指标（含磁盘% + 网络出入口 + 磁盘 IO 速率）** + 5s WS + 4 槽 UI + 列表 **+** |
| **M2** | 进程 Top5 抽屉 + **探针记录 Tab** + 历史趋势多序列（含带宽曲线） |
| **M3** | CSV 导出 + Agent 可选补 net_io（减 SSH）+ 告警联动 |

## 回滚

- 删除路由与 UI；停 `watch_probe`；表可保留；Redis key TTL 自愈。
