Files
Nexus/docs/design/specs/2026-06-02-push-refactor-design.md
T
2026-07-08 22:31:31 +08:00

14 KiB
Raw Blame History

推送页重构设计文档

日期: 2026-06-02
版本: v2.0(深度审计补充)


1. 已发现问题(逐行审计)

🔴 Critical(功能性 Bug

# 问题 位置 现象
C1 双进度条同时显示 L130172 v-if="pushing" 的进度条 + WS 进度列表同时渲染,信息重复且相互矛盾
C2 mdi-loading 不旋转 L162 running 状态图标使用 mdi-loading,该图标是静态的,无法表达"进行中"。正确写法是 mdi-loading mdi-spinmdi-progress-clock
C3 服务器卡片状态从不更新(WS 路径) L100102 WS 推送时 pushStatus[s.id] 永远不被赋值(只有 fallback 路径才写),服务器卡片上的 chip 始终为空
C4 selectAll computed setter 为空 L430433 v-model="selectAll" 绑定的 computed 的 set() => {}Vue 内部赋值被吞掉,checkbox 双向绑定失效,UI 和状态不同步
C5 取消推送把状态标为 'failed' L395399 cancelPush 将 pending/running 的 items 标记为 status = 'failed',混淆了"失败"和"已取消",历史图表/统计数据被污染
C6 toggleAll 忽略搜索过滤 L482488 搜索时 toggleAll 操作的是 servers.value(全部),而非 filteredServers,导致搜索过滤后「全选」会选中不可见的服务器

🔴 深度审计追加 CriticalFrontend↔Backend 契约断裂)

# 问题 位置 现象
C7 WS 字段名不匹配:error_message vs detail PushPage.vue L356/359/363 vs websocket.py L492 后端广播 error_message,前端读取 msg.detail错误信息从不显示
C8 后端从不广播 running 状态 sync_engine_v2.py L198214 broadcast_sync_progress 只在任务完成后调用,pending → 直接跳 success/failedloading 动画永远不激活
C9 预览 API 响应结构完全错配 PushPage.vue L182 vs sync_engine_v2.py L314325 后端返回 { stats: { files_total, ... }, files: string[] },前端期望 { files: number, size: string }预览对话框内容永远为空
C10 doPush 无重入保护 PushPage.vue L509 if (pushing.value) return 快速失败,快速双击可并发触发两次推送

🟡 Medium(架构 / 可维护性问题)

# 问题 位置
M1 WS URL 构造重复实现 L328336:内联了 getWsUrl(),不使用已有的 buildWebSocketUrl 工具,逻辑不一致
M2 复用告警 WS/ws/alerts)传输推送进度 L338–377:推送进度通过全局告警频道过滤 batch_id,理论上两个用户同时推送会串扰;WS 断开后 pending 状态永远不更新
M3 预览只对第一台服务器执行 L499selectedIds.value.values().next().value,多选时只预览第一台,误导性强
M4 _pushTimers 混用 setTimeout/setInterval L261/L588:类型声明 setTimeout[],实际 push 了 setInterval 返回值,onUnmountedclearTimeout 无法清理 interval,导致定时器泄漏
M5 wsSocket 是模块级 let 变量 L326:组件多次挂载/卸载不会重置,可能引用旧 socket
M6 PushItem 类型定义与 API 响应脱节 types/api.ts L176185:缺少 sync_modeerror_messageduration_secondsoperatortrigger_type 字段,后端实际都返回了
M7 doPush fallback 路径逻辑矛盾 L539–569:批量 API 只在网络层失败时触发 fallback,而此 fallback 又重新调相同的 /sync/files 但没接 WS,进度追踪完全失效;实际上此 fallback 几乎永远不会触发
M8 后端 N+1 DB 查询 sync_engine_v2.py L7578for sid in server_ids: await repo.get_by_id(sid)100 台服务器 = 100 次 DB 往返
M9 /tmp/nexus_upload_* 清理机制 sync_v2.py L949:ZIP 解压后临时目录从不自动删除,服务器 /tmp 会随时间积累
M10 setInterval 完成检测可由 WS onmessage 替代 L573587:用 500ms polling 检测推送完成,完全可以在 WS 消息里直接判断,消除不必要的定时器
M11 单文件 633 行,无 composable/组件拆分 整个页面逻辑扁平

🟢 Minor(体验问题)

# 问题
E1 目标路径为空时可以静默推送(无校验)
E2 推送历史表格缺少 sync_modeerror_messageduration_seconds
E3 WS 意外断线时(非 5min 超时),pending 项目无任何提示
E4 取消推送后进度卡片仍可能显示取消按钮(条件判断未包含 cancelled
E5 上传 ZIP 后 sourcePath 输入框仍可编辑,但 effectiveSourcePath() 会忽略它,UI 与逻辑不一致
E6 服务器在线状态显示 DB 静态值,非 Redis 实时心跳状态

2. 修复方案

C1 — 合并两个进度区域

删除旧的 v-if="pushing" 进度条(含 pushProgress + completedCount)。
所有进度统一由 wsProgressItems 驱动,始终显示 per-server 粒度进度。
总进度 computed 由 wsProgressItems 派生:

const doneCount = computed(() => wsProgressItems.value.filter(p => p.status === 'success' || p.status === 'failed' || p.status === 'cancelled').length)
const totalProgress = computed(() => wsProgressItems.value.length ? Math.round(doneCount.value / wsProgressItems.value.length * 100) : 0)

C2 — 修复旋转图标

<!-- 修复前 -->
<v-icon v-else-if="s.status === 'running'" color="blue">mdi-loading</v-icon>
<!-- 修复后 -->
<v-icon v-else-if="s.status === 'running'" color="blue" class="mdi-spin">mdi-loading</v-icon>

C3 — WS 推送时同步更新服务器卡片

在 WS onmessage 处理中,同步写入 pushStatus

if (msg.status === 'success') {
  item.status = 'success'
  pushStatus.value[msg.server_id] = '成功'
} else if (msg.status === 'failed') {
  item.status = 'failed'
  pushStatus.value[msg.server_id] = '失败'
}

C4 — 修复 selectAll 双向绑定

v-model="selectAll" 改为 :model-value="selectAll" + @update:model-value="toggleAll"
同时修复 selectAll computed 使其只检查 filteredServers(与搜索对齐):

const selectAll = computed(() =>
  filteredServers.value.length > 0 &&
  filteredServers.value.every(s => selectedIds.value.has(s.id))
)

C5 — 新增 cancelled 状态

type ServerProgressStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled'

// cancelPush 中:
item.status = 'cancelled'
item.detail = '已取消'

模板增加对应图标和颜色:

<v-icon v-else-if="s.status === 'cancelled'" color="grey">mdi-cancel</v-icon>

C6 — toggleAll 只操作 filteredServers

function toggleAll() {
  const visible = filteredServers.value
  const allSelected = visible.every(s => selectedIds.value.has(s.id))
  const s = new Set(selectedIds.value)
  for (const srv of visible) {
    if (allSelected) s.delete(srv.id)
    else s.add(srv.id)
  }
  selectedIds.value = s
}

M1 — 统一 WS URL 构造

删除 getWsUrl(),改用 buildWebSocketUrl

import { buildWebSocketUrl } from '@/utils/wsUrl'

function connectProgressWs() {
  if (!auth.token) return
  const url = buildWebSocketUrl('/ws/alerts', { token: auth.token })
  // ...
}

C7 — 修复 WS 字段名映射

// onmessage 中统一用 error_message,不用 detail
if (msg.status === 'running') {
  item.status = 'running'
  item.detail = '同步中...'
} else if (msg.status === 'success') {
  item.status = 'success'
  item.detail = `完成${msg.duration_seconds ? `${msg.duration_seconds}s` : ''}`
  pushStatus.value[msg.server_id] = '成功'
} else if (msg.status === 'failed') {
  item.status = 'failed'
  item.detail = msg.error_message || '失败'   // ← 修复:error_message not detail
  pushStatus.value[msg.server_id] = '失败'
} else if (msg.status === 'cancelled') {
  item.status = 'cancelled'
  item.detail = '已取消'
}

C8 — 后端补发 running 广播

sync_engine_v2.py_sync_one 创建 SyncLog 后立即广播 running

# 在 sync_log = await self.sync_log_repo.create(sync_log) 之后
try:
    await broadcast_sync_progress(
        batch_id=batch_id,
        server_id=server.id,
        server_name=server.name,
        status="running",
        completed=completed,
        failed=failed,
        total=total,
    )
except Exception as e:
    logger.debug(f"WS running broadcast failed for server {server.id}: {e}")

C9 — 修复预览对话框数据读取

// PreviewData 类型对齐后端响应
interface PreviewData {
  error?: string
  stats?: {
    files_total?: number
    files_created?: number
    files_deleted?: number
    files_transferred?: number
    total_size_bytes?: number
    transfer_size_bytes?: number
  }
  files?: string[]
  files_truncated?: boolean
  server_name?: string
}

模板改为读取 previewData.stats

<div v-if="previewData.stats" class="text-body-2">
  {{ previewData.stats.files_total ?? 0 }} 个文件,
  将传输 {{ previewData.stats.files_transferred ?? 0 }} 个,
  {{ formatSize(previewData.stats.transfer_size_bytes ?? 0) }}
</div>

C10 — doPush 重入保护

async function doPush() {
  if (pushing.value) return   // ← 新增
  if (!validateForm()) return
  // ...
}

M4 — 分离 timers

const _pushIntervals: ReturnType<typeof setInterval>[] = []
const _pushTimeouts: ReturnType<typeof setTimeout>[] = []

// onUnmounted:
_pushIntervals.forEach(t => clearInterval(t))
_pushTimeouts.forEach(t => clearTimeout(t))

M5 — wsSocket 改为 ref

const wsSocket = ref<WebSocket | null>(null)

E1 — 目标路径验证

function validateForm(): boolean {
  if (!effectiveSourcePath()) {
    snackbar('请选择源路径或上传 ZIP 文件', 'warning')
    return false
  }
  if (!targetPath.value.trim()) {
    snackbar('请填写目标路径', 'warning')
    return false
  }
  if (selectedIds.value.size === 0) {
    snackbar('请选择至少一台服务器', 'warning')
    return false
  }
  return true
}

M8 — 后端批量查询替代 N+1

# sync_engine_v2.py
servers = await self.server_repo.get_by_ids(server_ids)  # 单次查询

需在 ServerRepositoryImpl 增加 get_by_ids(ids: list[int]) 方法:

async def get_by_ids(self, ids: list[int]) -> list[Server]:
    from sqlalchemy import select
    result = await self.session.execute(
        select(Server).where(Server.id.in_(ids))
    )
    return list(result.scalars().all())

M9 — 临时目录定期清理

background/ 添加清理任务,或在 ZIP 推送完成后主动删除:

# sync_engine_v2.py sync_files() 执行完后
if source_path.startswith("/tmp/nexus_upload_"):
    import shutil
    shutil.rmtree(source_path, ignore_errors=True)
    logger.info(f"Cleaned up upload temp dir: {source_path}")

M10 — 消除 setInterval,改为 onmessage 驱动完成检测

// onmessage 中调用
function _checkPushComplete() {
  const total = wsProgressItems.value.length
  const done = wsProgressItems.value.filter(
    p => p.status === 'success' || p.status === 'failed' || p.status === 'cancelled'
  ).length
  if (total > 0 && done >= total) {
    pushing.value = false
    disconnectProgressWs()
    loadLogs()
    const failed = wsProgressItems.value.filter(p => p.status === 'failed').length
    const cancelled = wsProgressItems.value.filter(p => p.status === 'cancelled').length
    snackbar(`推送完成:${done - failed - cancelled} 成功,${failed} 失败,${cancelled} 取消`)
  }
}

E3 — WS 意外断线兜底

socket.onclose = (ev) => {
  wsSocket.value = null
  // 非正常关闭(code !== 1000)且仍有 pending/running 项目时提示
  if (ev.code !== 1000) {
    wsProgressItems.value.forEach(p => {
      if (p.status === 'pending' || p.status === 'running') {
        p.status = 'failed'
        p.detail = '连接中断'
      }
    })
    snackbar('推送进度连接中断,部分服务器状态未知', 'warning')
    pushing.value = false
    loadLogs()
  }
}

3. 拆分规划(组件/composable

frontend/src/
├── pages/
│   └── PushPage.vue                 (~120行,仅组合)
├── composables/push/
│   ├── usePushSessions.ts           (WS 连接、进度追踪、取消)
│   ├── usePushForm.ts               (源路径、目标路径、同步模式、校验)
│   └── usePushLogs.ts               (历史记录分页加载)
└── components/push/
    ├── PushServerGrid.vue           (服务器分组网格 + 搜索 + 全选)
    ├── PushProgressList.vue         (进度列表 + 取消按钮)
    └── PushHistoryTable.vue         (历史记录表格)

4. 实施阶段

阶段 范围 涉及文件 优先级
R1 C1C6:前端 Critical Bug PushPage.vue 最高
R2 C7C10 + M1/M4/M5/M10 PushPage.vuewebsocket.pysync_engine_v2.py 高(含 Backend
R3 M6/M8/M9 + PushItem 类型补全 + 临时目录清理 types/api.tsserver_repo.pysync_engine_v2.py
R4 E1–E6 体验细节 + 推送历史表补列 PushPage.vue
R5 组件/composable 拆分(M11/M7 新建 composables/push/components/push/ 低(架构优化)

5. 验收标准

功能正确性

  • 推送过程中只显示一个进度区域
  • running 状态显示旋转动画(后端广播 + 前端渲染)
  • 进度列表中失败服务器显示具体错误信息
  • 预览对话框正确显示文件数量和传输大小
  • 推送时服务器卡片 chip 实时更新(WS 路径)
  • 取消推送后状态为「已取消」而非「失败」

交互正确性

  • 全选 checkbox 与 Vue 双向绑定正常工作
  • 搜索后全选仅选中可见服务器
  • 快速双击推送按钮不触发重复推送
  • 目标路径为空时阻止推送并提示

资源管理

  • WS 意外断线时标记未完成项目并提示用户
  • onUnmounted 正确清理所有 interval 和 timeout(无 setInterval 泄漏)
  • ZIP 推送完成后临时目录自动清理

性能

  • 批量推送 DB 查询从 N 次降到 1 次