Files
Nexus/docs/design/specs/2026-06-12-server-watch-slots-design.md
T
2026-07-08 22:31:31 +08:00

387 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计 — 服务器实时监测槽(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 |
|------|---------------|-----------------|---------------------|
| RedisAgent 心跳) | ✅ `system_info` 已有 | ⚠ 心跳 **默认无** → Pin 机走 SSH 补采 **或** Agent 小改带上 `net_io`/`disk_io`(推荐 **M1SSH 脚本一次拿全** |
| 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 返回 422snackbar 说明原因(不静默失败)。
### 独立历史页
- 路由:`#/watch-metrics`(侧栏「监测历史」或从 WatchSlotRow「查看历史 →」进入)
- **Tab A — 资源趋势**:选 14 台、时间范围 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 510s 更新内容
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}` — 进程 JSONTTL 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 时 connectunmount 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 自愈。