feat: 推送页面 Round 4 — 服务器搜索 / Diff对比 / 排错诊断 / 源路径输入 / 批量重试
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

5 项新功能:
- H2: 服务器搜索过滤 — 目标服务器下拉加搜索框实时过滤
- H3: 推送对比 Diff 视图 — 预览文件列表加 diff 按钮对比本地与远程差异
- H4: 推送排错面板 — 失败行加诊断按钮检查SSH/磁盘/权限
- H5: 源路径直接输入 — 支持直接输入服务器本地路径推送
- H6: 批量重试 — 一键重试所有失败服务器

新增后端端点:
- POST /api/sync/validate-source-path (H5)
- POST /api/sync/diagnose (H4)
- POST /api/sync/file-diff (H3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-29 21:29:41 +08:00
parent 02423cb803
commit 5958c21048
9 changed files with 1436 additions and 331 deletions
+71
View File
@@ -0,0 +1,71 @@
# 2026-05-29 推送页面 Round 3 迭代
## 变更摘要
1. G1: 推送取消 — Redis 取消标记 + 前端"取消推送"按钮 + WS cancelled 状态
2. G2: 推送页面直接调度 — "⏰ 定时推送"按钮 + 时间选择模态框 + 调用调度 API
3. G3: 推送模板/收藏 — localStorage 模板存储 + 下拉选择 + 保存/删除
4. G4: 推送历史筛选分页 — 后端 page/per_page 参数 + 前端状态/服务器筛选 + 分页控件
5. G5: 浏览器推送完成通知 — Web Notification API + title 闪烁降级
6. G6: 拖拽上传 ZIP — 拖拽区 dragover/drop + 高亮 + click 回退
## 动机
推送页面 Round 1+2 已完成 10 项功能,但仍有 6 项体验/效率缺陷:推送无法取消导致只能等超时、定时推送需跳到调度页重新配置、常用推送组合每次手动选、历史无法筛选分页、离开页面不知推送结果、文件选择器不够直观。
## 涉及文件
### 后端
- `server/api/schemas.py` — 新增 `SyncCancel` 模型 (batch_id: str, max_length=32)
- `server/api/sync_v2.py` — 新增 `POST /api/sync/cancel` 端点
- Redis SET `sync:cancel:{batch_id}` EX 3600
- JWT 认证 + 审计日志
- 返回 `{cancelled: true, batch_id: ...}`
- `server/api/websocket.py``broadcast_sync_progress()` 增加 `cancelled: int = 0` 参数
- WS 消息新增 `cancelled` 字段,前端用于正确计算进度条
- `server/application/services/sync_engine_v2.py` — 推送取消检查
- `_sync_one()`: 获取信号量后、rsync 前检查 `sync:cancel:{batch_id}`
- 取消的服务器 status="cancelled",创建 SyncLog 记录
- 新增 `cancelled` 计数器,与 `completed`/`failed` 并列
- 正常路径 WS 广播也传递 `cancelled` 计数
- Redis 异常不再静默吞错,改用 `logger.warning()`
- `server/api/servers.py``get_all_sync_logs()` 参数改为 page/per_page + 返回 pages
- 旧: `offset: int, limit: int` → 新: `page: int = 1, per_page: int = 20`
- 返回增加: `page`, `per_page`, `pages` (ceil(total/per_page))
### 前端
- `web/app/push.html` — 6 项改动
- G1: 推送进度区"取消推送"按钮 + `cancelPush()` + WS cancelled 状态渲染 ⊘
- G2: "⏰ 定时推送"按钮 + 时间选择模态框 + `doSchedulePush()`
- G3: 模板下拉框 + `saveTemplate()`/`applyTemplate()`/`deleteTemplate()` (localStorage)
- G4: 历史区状态筛选下拉 + 服务器ID输入 + 分页控件
- G5: 推送完成时 `new Notification()` + 授权拒绝时 title 闪烁降级
- G6: 拖拽上传区 dragover/drop/dragleave + 高亮边框 + click 回退
- 安全: `esc()` 函数增加单引号转义 `&#39;`,防止 onclick 属性 JS 注入
## 安全影响
- G1 (取消): Redis key 有 1h TTL 防泄漏;batch_id 为服务器生成的 UUID hex;取消端点有 JWT 认证 + 审计
- G2 (调度): 复用现有调度 API,已有 JWT 认证 + 审计
- G3 (模板): localStorage 仅存路径/服务器ID,不含密码/密钥
- G4 (分页): SQLAlchemy 参数化查询,无 SQL 注入风险
- G5 (通知): Notification API 需用户主动授权,非强制
- G6 (拖拽): 文件仍走 upload-zip 端点校验
## 审计修复
- F1: 取消服务器计数器 → 新增 `cancelled` 计数器,WS 广播含 `cancelled` 字段,前端进度条正确计算
- F2: 静默异常 → `except Exception: pass` 改为 `logger.warning()`
- F3: 异常链抑制 → 移除 `from None`
- F4: XSS 防御 → `esc()` 增加单引号转义
## 需要迁移/重启
- 是:后端 Python 需重启(supervisorctl restart nexus
- 否:无数据库 schema 迁移
## 验证方式
1. G1: 推送 10 台 → 点击"取消推送" → 未启动的显示"已取消" ⊘ → 进度条正确
2. G2: 配置推送 → 点击"⏰ 定时推送" → 选择时间 → 调度页出现任务
3. G3: 保存模板 → 刷新 → 选择模板 → 表单自动填充
4. G4: 历史区筛选"失败" → 仅显示失败 → 分页翻页
5. G5: 推送完成 → 浏览器弹出通知
6. G6: 拖拽 ZIP 到上传区 → 高亮 → 释放上传成功
## 进度条
☑实现 ☑WSL验证 ☑审计8步 ☐部署 ☐健康检查 ☐浏览器验证 ☑changelog
+50
View File
@@ -0,0 +1,50 @@
# 2026-05-29 推送页面 Round 4 迭代
## 变更摘要
1. H2: 服务器搜索过滤 — 目标服务器下拉列表上方加搜索框,按名称/域名实时过滤
2. H3: 推送对比 Diff 视图 — 预览文件列表中加"📄 diff"按钮,点击后对比本地与远程文件内容差异
3. H4: 推送排错面板 — 失败行加"🔍 诊断"按钮,检查 SSH 连通/磁盘空间/目标路径权限/写入权限
4. H5: 源路径直接输入 — 除 ZIP 上传外,支持直接输入服务器本地路径推送
5. H6: 批量重试 — 推送完成后,一键重试所有失败服务器
## 动机
推送页面功能已较完善,但 5 个实际使用痛点:2000+ 服务器下拉无法搜索定位、推送前无法看到文件内容差异、推送失败后需手动 SSH 排查、已有文件需先下载再上传、批量失败需逐台点重试。
## 涉及文件
### 后端
- `server/api/schemas.py` — 新增 `ValidateSourcePath`, `SyncDiagnose`, `FileSyncDiff` 三个 Schema
- `server/api/sync_v2.py` — 新增 3 个端点
- `POST /api/sync/validate-source-path`: 验证本地目录路径有效性(禁止 /etc /root /home 等敏感路径)
- `POST /api/sync/diagnose`: 诊断推送失败原因(SSH 连通 → 磁盘空间 → 路径权限 → 写入测试)
- `POST /api/sync/file-diff`: 对比本地文件与远程文件差异(difflib.unified_diff,限 100KB
- `docs/design/specs/2026-05-29-push-round4-design.md` — 设计文档
- `docs/design/plans/2026-05-29-push-round4.md` — 技术文档
### 前端
- `web/app/push.html` — 5 项改动
- H2: `<input id="serverSearch">` + `filterServerOptions()` 按 oninput 实时过滤
- H3: 预览文件列表渲染 HTML 带 diff 按钮 + `showFileDiff()` + diff 模态框(新增绿/删除红/头部青)
- H4: 服务器行加 `srv_diag_{id}` 按钮 + `diagnoseServer()` + 诊断模态框
- H5: "或输入源路径" 输入框 + "验证路径" 按钮 + `validateSourcePath()`
- H6: 进度条区域"🔄 重试全部失败"按钮 + `retryAllFailed()` 批量提交重试
## 安全影响
- H5 (源路径验证): `os.path.realpath()` 校验 + 禁止 `/etc` `/root` `/home` `/var` 等敏感路径前缀
- H3 (文件对比): realpath 校验 local_file 必须在 source_path 下,source_path 必须 `startswith("/tmp/nexus_upload_")`
- H4 (诊断): `shlex.quote()` 防注入,SSH 操作复用连接池
- 所有新端点需 JWT 认证 + 审计日志
## 需要迁移/重启
- 是:后端 Python 需重启(supervisorctl restart nexus
- 否:无数据库 schema 迁移
## 验证方式
1. H2: 输入 "web" → 下拉只显示名称/域名含 "web" 的服务器
2. H3: 预览 → 文件列表 → 点 "📄 diff" → 显示逐行对比(新增绿/删除红)
3. H4: 推送失败 → 点"🔍 诊断" → SSH/磁盘/权限检查结果
4. H5: 输入 `/tmp/nexus_upload_xxx` → 验证成功 → 可推送
5. H6: 推送完成后有失败 → 显示"重试全部失败"按钮 → 点击批量重试
## 进度条
☑实现 ☑WSL验证 ☐审计8步 ☑部署 ☐健康检查 ☐浏览器验证 ☑changelog
+135
View File
@@ -0,0 +1,135 @@
# 2026-05-29 推送页面迭代 Round 3 — 技术文档
## 涉及文件清单
| 文件 | 改动类型 | 涉及功能 |
|------|---------|---------|
| `server/application/services/sync_engine_v2.py` | 修改 | G1 取消检查 |
| `server/api/sync_v2.py` | 新增端点 | G1 取消端点 |
| `server/api/schemas.py` | 新增模型 | G1 取消请求 |
| `server/api/servers.py` | 修改参数 | G4 历史筛选分页 |
| `web/app/push.html` | 修改 | G1-G6 全部前端 |
| `docs/changelog/2026-05-29-push-round3.md` | 新增 | changelog |
## 实现步骤
### Step 1: G1 推送取消(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增:
```python
class SyncCancel(BaseModel):
batch_id: str = Field(..., min_length=1, max_length=32)
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/cancel`:
- Redis `SET sync:cancel:{batch_id} 1 EX 3600`
- 审计日志
- 返回 `{"cancelled": true, "batch_id": ...}`
3. `server/application/services/sync_engine_v2.py`:
- `_rsync_push()`: 执行前检查 `sync:cancel:{batch_id}`,存在则返回 `{"exit_code": -1, "stderr": "推送已取消"}`
- `sync_files()`: `_sync_one()` 任务启动前(`async with sem` 之后、rsync 之前)检查取消标记
- 取消的服务器 status=`"cancelled"`,不创建 retry job
**前端**:
1. `doPush()` 执行期间显示"取消推送"按钮
2. `cancelPush()` — 调用 `POST /api/sync/cancel`
3. `updateProgressFromWS()` — 处理 `status=cancelled`
4. 取消后按钮隐藏,已完成的服务器结果保留
### Step 2: G6 拖拽上传 ZIP(纯前端)
**文件**: `web/app/push.html`
1. 将 `#sourceUpload` 区域改为拖拽区:
- `<div>` 加 `dragover/dragleave/drop` 事件
- `dragover`: 高亮边框 `border-brand`
- `drop`: 取 `ev.dataTransfer.files[0]`,调用 `uploadZip()` 逻辑
2. 保留 `<input type="file">` 隐藏,拖拽区 click 触发 `zipFile.click()`
3. 文件类型校验:仅 `.zip`
### Step 3: G5 浏览器推送完成通知(纯前端)
**文件**: `web/app/push.html`
1. `doPush()` 开始时请求 `Notification.requestPermission()`
2. 推送完成(`doPush()` finally 块前)发送通知:
```js
if (Notification.permission === 'granted') {
new Notification('Nexus 推送完成', {body: `${completed} 成功, ${failed} 失败`});
}
```
3. 降级:授权拒绝时 `document.title` 闪烁(3 次交替 `🔔 Nexus` / `Nexus — 推送`
### Step 4: G2 推送页面直接调度(纯前端)
**文件**: `web/app/push.html`
1. "开始推送"按钮旁加"⏰ 定时推送"按钮
2. 点击弹出简单模态框:`datetime-local` 选择器 + 确认按钮
3. 确认后调用 `POST /api/schedules/`body 包含:
- `name`: "手动推送-{时间}"
- `schedule_type`: "push"
- `run_mode`: "once"
- `fire_at`: 选择的时间
- `source_path`, `server_ids`, `sync_mode`: 从当前表单取
4. 成功后 toast 提示 + 链接到调度页面
### Step 5: G3 推送模板/收藏(纯前端)
**文件**: `web/app/push.html`
1. localStorage key: `nexus_push_templates` — JSON 数组
2. 模板结构: `{name, source_path, target_path, sync_mode, server_ids[]}`
3. UI:
- 表单上方"模板"下拉框 + "保存当前配置"按钮
- 选择模板 → 自动填充 source_path/target_path/sync_mode/server_ids
- 下拉旁"删除"按钮删除模板
4. `saveTemplate()`: 弹出 prompt 输入名称 → 存入 localStorage
5. `loadTemplate()`: 从 localStorage 读取模板列表 → 渲染下拉框
6. `applyTemplate()`: 填充表单字段
7. `deleteTemplate()`: 从 localStorage 移除
### Step 6: G4 推送历史筛选(后端+前端)
**后端**:
1. `server/api/servers.py` — `list_sync_logs()`:
- 新增参数: `status: Optional[str]`, `server_id: Optional[int]`, `page: int = 1`
- 增加过滤条件到 SQLAlchemy 查询
- 增加 `COUNT(*)` 查询获取总数
- 返回增加: `total`, `page`, `pages`
**前端**:
1. 历史区增加筛选控件:
- 状态下拉: 全部/成功/失败
- 服务器搜索输入框
2. `loadHistory()` 增加参数传递
3. 分页控件: 上一页/下一页 + 页码显示
## 依赖与配置变更
无新依赖。无数据库 schema 迁移。
G1 需要已有的 Redis 连接。
## 测试要点
1. G1: 推送 10 台 → 取消 → 仅已完成的有结果,其余显示"已取消"
2. G1: 推送完成后再取消 → 无效果(batch_id 已失效)
3. G2: 定时推送 → 调度页出现对应任务 → 到时间自动执行
4. G3: 保存模板 → 刷新 → 选择模板 → 表单填充正确
5. G3: 删除模板 → 下拉框更新
6. G4: 筛选"失败" → 仅显示失败记录 → 分页翻页
7. G5: 推送完成 → 浏览器通知弹出 → 点击通知回到页面
8. G6: 拖拽 ZIP → 上传解压成功 → 文件管理器显示
9. G6: 拖拽非 ZIP → 提示仅支持 .zip
## 回滚方式
所有改动均为增量(新增端点/前端元素),回滚只需:
1. `git revert` 提交
2. `supervisorctl restart nexus`
+138
View File
@@ -0,0 +1,138 @@
# 2026-05-29 推送页面迭代 Round 4 — 技术文档
## 涉及文件清单
| 文件 | 改动类型 | 涉及功能 |
|------|---------|---------|
| `server/api/schemas.py` | 新增模型 | H3 diff + H4 diagnose + H5 validate |
| `server/api/sync_v2.py` | 新增端点 | H3 file-diff + H4 diagnose + H5 validate |
| `web/app/push.html` | 修改 | H2 搜索 + H3 diff UI + H4 诊断 UI + H5 源路径 + H6 批量重试 |
| `docs/changelog/2026-05-29-push-round4.md` | 新增 | changelog |
## 实现步骤
### Step 1: H2 服务器搜索过滤(纯前端)
**文件**: `web/app/push.html`
1. `<select id="targetServers">` 上方加 `<input id="serverSearch" placeholder="搜索服务器...">`
2. 新增 `filterServerOptions()` 函数:读取搜索词,遍历 `<select>``<option>`,按名称/域名匹配隐藏/显示
3. `filterServers()` 加载完成后调用 `filterServerOptions()`
4. 搜索框 `oninput` 事件绑定 `filterServerOptions()`
### Step 2: H5 源路径直接输入(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增 `ValidateSourcePath(BaseModel)`:
```python
class ValidateSourcePath(BaseModel):
path: str = Field(..., min_length=1)
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/validate-source-path`:
- 安全: `os.path.realpath()` 校验,禁止 `/etc`, `/root`, `/home`, `/var` 等敏感前缀
- 验证: 存在 + 是目录 + 统计文件数和大小
- 返回: `{valid, is_dir, file_count, size_bytes}`
- 审计日志
**前端**:
1. 上传区域下方加:
```html
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-slate-500">或输入源路径:</span>
<input id="sourcePathInput" placeholder="/path/to/files" class="flex-1 ...">
<button onclick="validateSourcePath()">验证</button>
</div>
```
2. `validateSourcePath()`: 调用 API → 成功后设置 `_uploadSourcePath` + 显示验证结果
3. `clearUpload()` 同时清空源路径输入框
### Step 3: H6 批量重试(纯前端)
**文件**: `web/app/push.html`
1. 进度条区域,`countFailed` 旁加:
```html
<button id="retryAllBtn" class="hidden text-xs text-brand-light hover:underline" onclick="retryAllFailed()">🔄 重试全部失败</button>
```
2. `updateProgressFromWS/Result()` — 有失败时显示 `retryAllBtn`,文本显示失败数
3. 新增 `retryAllFailed()`:
- 收集所有可见的 `srv_retry_{id}` 按钮的 `retryJobId`
- 逐个调用 `POST /api/retries/{id}/retry`
- 显示进度 toast
### Step 4: H4 推送排错面板(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增 `SyncDiagnose(BaseModel)`:
```python
class SyncDiagnose(BaseModel):
server_id: int = Field(..., ge=1)
target_path: Optional[str] = None
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/diagnose`:
- 检查项(依次执行,遇短路可提前返回):
a. SSH 连通性: 尝试 `exec_ssh_command(server, "echo ok", timeout=10)`
b. 磁盘空间: `df -h {target_path}` → 解析 avail/usage%
c. 目标路径: `ls -ld {target_path}` → 解析权限 + owner
d. 写入测试: `touch {target_path}/.nexus_test_$$ && rm {target_path}/.nexus_test_$$`
- 返回: `{ssh_ok, ssh_error?, disk_avail, disk_used_pct, path_exists, path_perms, path_writable, errors[]}`
- 审计日志
**前端**:
1. `showProgress()` 每行加 `srv_diag_{id}` 诊断按钮(仅失败时显示)
2. 新增 `diagnoseServer(serverId)` — 调用 API → 弹出模态框显示检查结果
3. 模态框 `#diagModal`: 4 项检查,每项 ✓/✗ 图标 + 详情
### Step 5: H3 推送对比 Diff 视图(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增 `FileSyncDiff(BaseModel)`:
```python
class FileSyncDiff(BaseModel):
server_id: int = Field(..., ge=1)
source_path: str = Field(..., min_length=1)
relative_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/file-diff`:
- 安全: `os.path.realpath(local_file)` 必须 `startswith(source_path)` 且 source_path 必须 `startswith("/tmp/nexus_upload_")`
- 读本地文件: 最多 100KB
- 读远程文件: `cat {shlex.quote(remote_file)}` via SSH,最多 100KB
- diff 计算: Python `difflib.unified_diff()` → 返回 `diff_lines: [{type: "add"|"del"|"ctx", content}]`
- 返回: `{file_name, local_exists, remote_exists, local_size, remote_size, diff_lines, truncated}`
- 审计日志
**前端**:
1. `renderPreviewResult()` verbose 文件列表中每行加 "📄 diff" 按钮
2. 新增 `showFileDiff(serverId, relativePath)` — 调用 API → 弹出 diff 模态框
3. 模态框 `#diffModal`: 文件名 + 左右对比或逐行显示(新增绿底、删除红底、上下文灰底)
## 依赖与配置变更
无新依赖。无数据库 schema 迁移。
## 测试要点
1. H2: 输入 "web" → 下拉只显示名称/域名含 "web" 的服务器
2. H5: 输入 `/tmp/nexus_upload_xxx` → 验证成功 → 可推送
3. H5: 输入 `/etc` → 验证拒绝(敏感路径)
4. H6: 推送 3 台失败 2 台 → 点"重试全部失败" → 2 台重试
5. H4: 推送失败 → 点"🔍 诊断" → SSH/磁盘/权限检查结果
6. H3: 预览 → 文件列表 → 点 "diff" → 显示逐行对比
7. H3: 本地有文件远程无 → 显示为纯新增(全绿)
8. H3: 本地无远程有 → 显示为纯删除(全红)
## 回滚方式
所有改动均为增量(新增函数/端点/前端元素),回滚只需:
1. `git revert` 提交
2. `supervisorctl restart nexus`
@@ -0,0 +1,129 @@
# 2026-05-29 推送页面迭代 Round 3 — 设计文档
## 背景与目标
推送页面 Round 1(WS 进度/分组/历史/文件操作/校验)和 Round 2(同步说明/在线标识/失败重试/Telegram 通知/文件预览)已完成全部 10 项功能。基于实际运维场景,仍有 6 项体验/效率缺陷:
1. **推送无法取消** — 推送 50 台发现配错,只能等全部超时,无中止机制
2. **推送页无法直接调度** — 想凌晨推送需跳到调度页重新配置源路径/服务器/模式,操作割裂
3. **无推送模板/收藏** — 常用推送组合每次手动选,重复劳动
4. **推送历史无法筛选** — 历史只有最近 15 条无分页,无法按服务器/状态/日期过滤
5. **无浏览器推送完成通知** — 用户离开页面后不知推送结果(Telegram 有但浏览器没有)
6. **不支持拖拽上传 ZIP** — 文件选择器不够直观
## 方案对比
### G1 推送取消
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: Redis 取消标记 + 引擎轮询 | 简单可靠,无进程间通信 | 最多延迟一个 rsync 周期(5min)才取消 |
| B: asyncio.Task.cancel() | 即时取消 | 多 worker 下无法跨进程取消 |
| C: kill rsync 子进程 | 最直接 | 需跟踪 PID,复杂且风险高 |
**选定方案 A** — Redis `sync:cancel:{batch_id}` 键,sync_engine 的 `_rsync_push()` 每次执行前检查。前端显示"取消推送"按钮,取消后已完成的保留结果,未启动的跳过。简单可靠,无进程间通信问题。
### G2 推送页面直接调度
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 推送按钮旁加"定时推送"按钮 | 复用当前表单配置 | 需新增调度 API 调用 |
| B: 调度选择器嵌入推送页 | 更灵活 | 页面变重 |
**选定方案 A** — "开始推送"按钮旁增加"⏰ 定时推送"按钮,弹出时间选择器后直接调用 `POST /api/schedules/`。推送页已有所需全部字段(server_ids, source_path, sync_mode)。
### G3 推送模板/收藏
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 浏览器 localStorage | 无后端改动 | 仅本机可见 |
| B: 后端 PushTemplate 表 | 跨设备共享 | 需 DB 迁移 + API |
**选定方案 A** — localStorage 存储推送模板。模板包含名称、源路径、目标路径、同步模式、服务器 ID 列表。运维人员通常固定在一台电脑操作,localStorage 足够。若未来需跨设备可迁移到后端。
### G4 推送历史筛选
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 后端分页 + 前端筛选下拉 | 大数据量友好 | 需改 API |
| B: 纯前端筛选 | 无后端改动 | 15 条太少无法筛选 |
**选定方案 A** — 后端 `GET /api/servers/logs` 已有 `limit` 参数,增加 `status`/`server_id`/`page` 参数,前端加筛选控件+分页。
### G5 浏览器推送完成通知
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: Notification API | 系统级通知,即使页面在后台也可见 | 需用户授权 |
| B: 页面 title 闪烁 | 无需授权 | 仅标签页可见 |
**选定方案 A** — Web Notification API。推送开始时请求授权,完成时发浏览器通知。降级:授权拒绝时 title 闪烁。
### G6 拖拽上传 ZIP
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 拖拽区域 + click 回退 | 直观 | 需前端改动 |
**选定方案 A** — 将文件上传区改为拖拽区 + click 选择,标准 HTML5 拖拽实现。
## 选定方案及理由
全部选方案 A(最简方案),理由:
- G1: Redis 标记是跨 worker 最可靠的取消方案
- G2: 复用推送表单 + 现有调度 API,零后端新端点
- G3: localStorage 模板无需 DB 迁移,符合运维单人操作场景
- G4: 后端分页是大数据量唯一正确方案
- G5: Notification API 是唯一真正"后台通知"的方案
- G6: 拖拽上传是现代标准 UX
## 接口/数据模型
### G1: 推送取消
- Redis key: `sync:cancel:{batch_id}` (SET, TTL=3600s)
- `sync_engine_v2._rsync_push()`: 执行前检查 Redis key,存在则返回 `{exit_code: -1, stderr: "推送已取消"}`
- `sync_engine_v2.sync_files()`: 每个任务启动前检查取消标记
- 前端: `POST /api/sync/cancel``{batch_id: str}` → 写 Redis key + 审计日志
- WS 消息: 取消的服务器 status=`"cancelled"`
### G2: 推送页面直接调度
- 无新端点,复用 `POST /api/schedules/`
- 前端: "⏰ 定时推送"按钮 → 弹出 `datetime-local` 选择器 → 调用调度 API
### G3: 推送模板
- localStorage key: `nexus_push_templates` — JSON 数组
- 模板结构: `{name, source_path, target_path, sync_mode, server_ids[]}`
- 前端: 模板下拉选择 + 保存/删除按钮
### G4: 历史筛选
- 后端 `GET /api/servers/logs` 增加: `status: Optional[str]`, `server_id: Optional[int]`, `page: int = 1`, `per_page: int = 20`
- 返回增加: `total`, `page`, `pages`
- 前端: 状态/服务器筛选 + 分页控件
### G5: 浏览器通知
- 推送开始时 `Notification.requestPermission()`
- 推送完成时 `new Notification("Nexus 推送完成", {body: "X 成功, Y 失败"})`
- 降级: 授权拒绝时 `document.title` 闪烁
### G6: 拖拽上传
- `<div>` 拖拽区域 + `dragover/drop` 事件
- 保留原有 `<input type="file">` 作为 click 回退
- 拖拽时高亮边框
## 安全与性能约束
- G1: 取消端点需 JWT 认证 + 审计日志;Redis key 有 TTL 防止泄漏
- G2: 复用调度 API,已有 JWT 认证 + 审计
- G3: localStorage 无安全风险(不含密码/密钥)
- G4: 分页查询有索引 `idx_sync_logs_srv_start`
- G5: Notification 需用户主动授权,非强制
- G6: 拖拽文件仍走现有 upload-zip 端点校验
## 验收标准
1. G1: 推送进行中 → 点击"取消推送" → 未启动的服务器跳过 → 已完成的保留结果
2. G2: 配置推送 → 点击"定时推送" → 选择时间 → 调度页出现对应任务
3. G3: 保存模板 → 刷新页面 → 下拉选择模板 → 表单自动填充
4. G4: 历史区筛选状态/服务器 → 分页翻页 → 结果正确
5. G5: 推送完成 → 浏览器弹出通知(页面在后台也可见)
6. G6: 拖拽 ZIP 到上传区 → 文件上传解压成功
@@ -0,0 +1,96 @@
# 2026-05-29 推送页面迭代 Round 4 — 设计文档
## 背景与目标
推送页面已完成 Round 15项)+ Round 25项)+ Round 3(6项)共 16 项功能。实际使用中发现以下 5 项体验/功能缺口:
1. **2000+ 服务器无法快速定位** — 下拉列表滚动查找低效,缺少搜索过滤
2. **推送前无法预览内容差异** — 只知道哪些文件会传输,不知道具体改了什么
3. **推送失败无法快速诊断** — 失败后不知道是 SSH 不通、磁盘满、还是权限问题
4. **源路径仅支持 ZIP 上传** — 服务器已有文件需先下载再上传,重复劳动
5. **批量失败需逐台点重试** — 推送 50 台失败 20 台,需点 20 次重试按钮
## 方案对比
### H2 服务器搜索过滤
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: `<select>` 上方加搜索框 + 实时过滤 | 简单直观,不改变交互模式 | 无 |
| B: 替换为自定义下拉组件(带搜索) | 更灵活 | 改动大,多选交互复杂 |
**选定方案 A** — 在现有 `<select>` 上方加 `<input>` 搜索框,按名称/域名实时过滤选项
### H3 推送对比 Diff 视图
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 预览文件列表中,每行加"查看diff"按钮 | 按需加载,精确 | 需后端端点读远程文件 |
| B: 批量拉取所有文件diff | 一次性展示全貌 | 大量文件时性能差 |
| C: 推送完成后对比 | 有实际结果 | 不是"推送前"预览 |
**选定方案 A** — 预览文件列表中加"查看diff"按钮,点击后请求后端读本地+远程文件内容,前端渲染逐行 diff(新增绿/删除红/修改黄)
### H4 推送排错面板
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 失败行加"🔍 诊断"按钮,弹面板显示检查结果 | 上下文内操作,体验流畅 | 需后端端点 |
| B: 失败后自动诊断 | 全自动 | 每次失败都跑诊断,浪费资源 |
**选定方案 A** — 失败行加"🔍 诊断"按钮,后端端点依次检查 SSH 连通 → 磁盘空间 → 目标路径权限,返回结构化结果
### H5 源路径直接输入
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: ZIP 上传区域旁加"或输入服务器本地路径"输入框 | 不破坏现有流程 | 无 |
| B: 两种模式切换(上传/路径) | 界面清晰 | 增加交互步骤 |
**选定方案 A** — 上传区域下方加一行"或输入源路径"输入框 + "验证路径"按钮,验证后替代上传路径
### H6 批量重试
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 进度条区域加"重试所有失败"按钮 | 一键操作 | 无 |
| B: 勾选式批量重试 | 灵活选择 | 交互复杂 |
**选定方案 A** — 推送完成后,如果存在失败行,在进度条区域显示"重试全部失败(N台)"按钮
## 选定方案及理由
全部选方案 A(最简方案):
- H2/H5/H6 均为纯前端或轻量前端改动
- H3/H4 需后端新端点,但复用现有 SSH 执行能力
- 改动范围最小,风险最低
## 接口/数据模型
### H3: 新增 API 端点
- `POST /api/sync/file-diff``{server_id, source_path, relative_path, target_path?}``{diff_lines: [{type, content}], file_name, local_exists, remote_exists}`
- 新增 Schema: `FileSyncDiff`
### H4: 新增 API 端点
- `POST /api/sync/diagnose``{server_id, target_path?}``{ssh_ok, disk_info, path_exists, path_writable, errors[]}`
- 新增 Schema: `SyncDiagnose`
### H5: 新增 API 端点
- `POST /api/sync/validate-source-path``{path}``{valid, is_dir, file_count, size_bytes, error?}`
- 新增 Schema: `ValidateSourcePath`
## 安全与性能约束
- H3: 仅允许对比 `/tmp/nexus_upload_*` 目录下的文件(realpath 校验);远程文件通过 SSH cat 读取,限制 100KB
- H4: 诊断命令通过 `shlex.quote()` 防注入;SSH 操作复用连接池
- H5: realpath 校验 + 不允许 `/etc` `/root` `/home` 等敏感路径前缀;仅允许已存在目录
- H3/H4/H5 均需 JWT 认证 + 审计日志
- H2/H6 纯前端,无安全影响
## 验收标准
1. H2: 输入搜索文字 → 服务器下拉列表实时过滤匹配项
2. H3: 预览文件列表 → 点击"diff" → 弹出逐行对比视图
3. H4: 推送失败 → 点击"🔍 诊断" → 弹出面板显示 SSH/磁盘/权限检查结果
4. H5: 输入服务器本地路径 → 点验证 → 成功后可直接推送
5. H6: 推送完成后有失败 → 显示"重试全部失败"按钮 → 点击后所有失败服务器重试
+24
View File
@@ -152,6 +152,30 @@ class LocalFilePreview(BaseModel):
path: str = Field(..., min_length=1)
class ValidateSourcePath(BaseModel):
"""Validate a local directory path on the Nexus server as a push source."""
path: str = Field(..., min_length=1)
class SyncDiagnose(BaseModel):
"""Diagnose why a push failed for a specific server."""
server_id: int = Field(..., ge=1)
target_path: Optional[str] = None
class FileSyncDiff(BaseModel):
"""Compare a local file with its remote counterpart to show push diff."""
server_id: int = Field(..., ge=1)
source_path: str = Field(..., min_length=1)
relative_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
class SyncCancel(BaseModel):
"""Cancel an in-progress push by batch_id."""
batch_id: str = Field(..., min_length=1, max_length=32)
class SyncVerify(BaseModel):
"""Post-push verification: compare local source with remote target via md5sum."""
server_ids: List[int] = Field(..., min_length=1)
+332 -1
View File
@@ -9,7 +9,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff
from server.api.dependencies import get_server_service
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -382,6 +382,337 @@ async def _audit_sync(action: str, target_type: str, target_id: int, detail: str
logger.warning(f"Audit log write failed for {action} on {target_type}/{target_id}", exc_info=True)
@router.post("/cancel")
async def cancel_sync(
payload: SyncCancel,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Cancel an in-progress push by setting a Redis flag.
The sync engine checks this flag before each rsync execution.
Already-completed servers retain their results; pending ones are skipped.
"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
await redis.set(f"sync:cancel:{payload.batch_id}", "1", ex=3600)
except Exception as e:
raise HTTPException(status_code=500, detail=f"取消操作失败: {e}")
await _audit_sync(
"sync_cancel", "sync", 0,
f"取消推送: batch_id={payload.batch_id}", admin.username, request,
)
return {"cancelled": True, "batch_id": payload.batch_id}
# ── H5: Validate Source Path ──
_FORBIDDEN_PATH_PREFIXES = ("/etc", "/root", "/home", "/var", "/boot", "/dev", "/proc", "/sys", "/run", "/srv")
@router.post("/validate-source-path")
async def validate_source_path(
payload: ValidateSourcePath,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Validate a local directory path on the Nexus server as a push source.
Security: rejects sensitive system paths (/etc, /root, /home, etc.)
and paths with traversal components.
"""
import os
path = payload.path.strip()
# Reject path traversal
if ".." in path.split("/"):
raise HTTPException(status_code=400, detail="路径不允许包含 '..'")
real_path = os.path.realpath(path)
# Reject sensitive paths
for prefix in _FORBIDDEN_PATH_PREFIXES:
if real_path == prefix or real_path.startswith(prefix + "/"):
raise HTTPException(status_code=403, detail=f"不允许使用系统路径: {prefix}")
if not os.path.exists(real_path):
raise HTTPException(status_code=404, detail="路径不存在")
if not os.path.isdir(real_path):
raise HTTPException(status_code=400, detail="路径不是目录")
# Count files and total size
file_count = 0
total_size = 0
for root, _dirs, fnames in os.walk(real_path):
for fn in fnames:
file_count += 1
try:
total_size += os.path.getsize(os.path.join(root, fn))
except OSError:
pass
if file_count >= 10000:
break
if file_count >= 10000:
break
await _audit_sync(
"validate_source_path", "sync", 0,
f"验证源路径: {path} ({file_count} 文件)",
admin.username, request,
)
return {
"valid": True,
"is_dir": True,
"path": real_path,
"file_count": file_count,
"size_bytes": total_size,
"file_count_truncated": file_count >= 10000,
}
# ── H4: Diagnose Push Failure ──
@router.post("/diagnose")
async def diagnose_push(
payload: SyncDiagnose,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Diagnose why a push failed for a specific server.
Checks SSH connectivity, disk space, target path permissions, and write access.
"""
import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.database.server_repo import ServerRepositoryImpl
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
result = {
"server_id": payload.server_id,
"server_name": server.name,
"ssh_ok": False,
"disk_avail": None,
"disk_used_pct": None,
"path_exists": None,
"path_perms": None,
"path_writable": None,
"errors": [],
}
# 1. SSH connectivity
try:
r = await exec_ssh_command(server, "echo ok", timeout=10)
if r["exit_code"] == 0 and "ok" in r["stdout"]:
result["ssh_ok"] = True
else:
result["errors"].append(f"SSH 连接异常: {r['stderr'][:200] or 'exit code ' + str(r['exit_code'])}")
# Cannot continue if SSH fails
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH不通", admin.username, request)
return result
except Exception as e:
result["errors"].append(f"SSH 连接失败: {e}")
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH失败", admin.username, request)
return result
dest = payload.target_path or server.target_path or "/tmp/sync"
safe_dest = shlex.quote(dest)
# 2. Disk space
try:
r = await exec_ssh_command(server, f"df -h {safe_dest} | tail -1", timeout=10)
if r["exit_code"] == 0:
parts = r["stdout"].strip().split()
if len(parts) >= 6:
result["disk_avail"] = parts[3] # e.g. "50G"
try:
result["disk_used_pct"] = int(parts[4].replace("%", "")) # e.g. 72
except ValueError:
pass
except Exception:
pass
# 3. Target path existence + permissions
try:
r = await exec_ssh_command(server, f"ls -ld {safe_dest} 2>&1", timeout=10)
if r["exit_code"] == 0:
result["path_exists"] = True
parts = r["stdout"].strip().split()
if parts:
result["path_perms"] = parts[0] # e.g. "drwxr-xr-x"
else:
result["path_exists"] = False
result["errors"].append(f"目标路径不存在: {dest}")
except Exception:
pass
# 4. Write test
if result["path_exists"]:
try:
test_file = f"{dest.rstrip('/')}/.nexus_diag_test_$$"
r = await exec_ssh_command(
server,
f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}",
timeout=10,
)
result["path_writable"] = r["exit_code"] == 0
if r["exit_code"] != 0:
result["errors"].append(f"目标路径不可写: {r['stderr'][:200]}")
except Exception as e:
result["errors"].append(f"写入测试失败: {e}")
result["path_writable"] = False
else:
result["path_writable"] = False
await _audit_sync(
"diagnose", "server", payload.server_id,
f"诊断: SSH={'' if result['ssh_ok'] else ''} 路径={'' if result['path_exists'] else ''} 写入={'' if result['path_writable'] else ''}",
admin.username, request,
)
return result
# ── H3: File Diff View ──
_MAX_DIFF_SIZE = 102_400 # 100 KB
@router.post("/file-diff")
async def file_diff(
payload: FileSyncDiff,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Compare a local file with its remote counterpart to show push diff.
Security: only allows diffing files under /tmp/nexus_upload_* source paths.
Reads up to 100KB from each side. Returns unified diff lines.
"""
import os
import difflib
import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.database.server_repo import ServerRepositoryImpl
# Security: source_path must be under /tmp/nexus_upload_*
real_source = os.path.realpath(payload.source_path)
if not real_source.startswith("/tmp/nexus_upload_"):
raise HTTPException(status_code=403, detail="仅允许对比上传临时目录中的文件")
# Resolve local file path
local_file = os.path.realpath(os.path.join(payload.source_path, payload.relative_path))
if not local_file.startswith(real_source):
raise HTTPException(status_code=403, detail="文件路径越界")
if not os.path.isfile(local_file):
return {
"file_name": os.path.basename(payload.relative_path),
"local_exists": False,
"remote_exists": None,
"local_size": 0,
"remote_size": None,
"diff_lines": [],
"truncated": False,
"error": "本地文件不存在",
}
# Read local file
local_size = os.path.getsize(local_file)
try:
with open(local_file, "r", encoding="utf-8", errors="replace") as f:
local_text = f.read(_MAX_DIFF_SIZE)
except OSError as e:
raise HTTPException(status_code=500, detail=f"读取本地文件失败: {e}") from None
local_lines = local_text.splitlines(keepends=True)
local_truncated = local_size > _MAX_DIFF_SIZE
# Get server + target path
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
dest = payload.target_path or server.target_path or "/tmp/sync"
remote_file = f"{dest.rstrip('/')}/{payload.relative_path}"
# Read remote file
remote_exists = False
remote_size = None
remote_lines = []
remote_truncated = False
try:
r = await exec_ssh_command(
server,
f"wc -c {shlex.quote(remote_file)} 2>/dev/null && cat {shlex.quote(remote_file)}",
timeout=15,
)
if r["exit_code"] == 0:
remote_exists = True
output = r["stdout"]
# First line is wc -c output: " 12345 /path/file"
first_newline = output.index("\n") if "\n" in output else 0
wc_line = output[:first_newline].strip()
try:
remote_size = int(wc_line.split()[0])
except (ValueError, IndexError):
remote_size = None
remote_text = output[first_newline + 1:]
if remote_size is not None and remote_size > _MAX_DIFF_SIZE:
remote_truncated = True
remote_lines = remote_text.splitlines(keepends=True)
else:
# File doesn't exist on remote — treat as empty (all local lines are additions)
remote_exists = False
except Exception:
# SSH failed — report error
remote_exists = None
# Compute unified diff
diff_lines = []
for line in difflib.unified_diff(
remote_lines, local_lines,
fromfile=f"remote/{payload.relative_path}",
tofile=f"local/{payload.relative_path}",
lineterm="",
):
if line.startswith("+") and not line.startswith("+++"):
diff_lines.append({"type": "add", "content": line[1:]})
elif line.startswith("-") and not line.startswith("---"):
diff_lines.append({"type": "del", "content": line[1:]})
elif line.startswith("@@"):
diff_lines.append({"type": "header", "content": line})
elif line.startswith(" "):
diff_lines.append({"type": "ctx", "content": line[1:]})
await _audit_sync(
"file_diff", "server", payload.server_id,
f"文件对比: {payload.relative_path}",
admin.username, request,
)
return {
"file_name": os.path.basename(payload.relative_path),
"local_exists": True,
"remote_exists": remote_exists,
"local_size": local_size,
"remote_size": remote_size,
"diff_lines": diff_lines,
"truncated": local_truncated or remote_truncated,
}
# ── File Upload via SFTP ──
MAX_UPLOAD_FILE_SIZE = 104_857_600 # 100 MB
+461 -330
View File
File diff suppressed because it is too large Load Diff