feat: 推送页面 Round 4 — 服务器搜索 / Diff对比 / 排错诊断 / 源路径输入 / 批量重试
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:
@@ -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()` 函数增加单引号转义 `'`,防止 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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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 1(5项)+ Round 2(5项)+ 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: 推送完成后有失败 → 显示"重试全部失败"按钮 → 点击后所有失败服务器重试
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user