feat(local-tool): M9-4 — Frontend FW badge + 升級 modal + WS hot-fix
A 階段第四個 milestone、完整 Frontend FW UI(badge / modal / 8 種 reason 復原)+ backend WS hot-fix(補對稱於 flash 的 firmware WS endpoint)。 Frontend(13 修改 / 7 新檔): - 新 firmware/ component group (badge / upgrade-button / upgrade-dialog 4-phase / progress-view / error-view 8-reason / index) - Zustand store (firmware-store.ts) + WS hook (use-firmware-progress.ts) 對齊既有 useFlashProgress pattern - DeviceCard 整合 FirmwareBadge + FirmwareUpgradeButton - i18n: settings.firmware.* namespace (對齊 Design Spec §9 SoT) + devices.card.fwBadge.* (zh-TW + en, 57 leaf keys × 2 lang = 114 strings) - toast.ts ToastOptions interface (duration param) - types/device.ts: FW 衍生欄位 + FirmwareStage/Reason/ProgressEvent/ActiveTask types Backend WS hot-fix (3 檔): - ws/firmware_ws.go (50 行、純對稱 flash_ws.go) - ws/firmware_ws_test.go (165 行、2 smoke tests: broadcast + room isolation) - router.go: GET /ws/devices/:id/firmware-progress 關鍵設計: - R-FW-11 緩解: upgrading phase modal 不可關 (onInteractOutside/onEscapeKeyDown preventDefault + 隱藏 X) - 多裝置隔離 defense in depth: store handleEvent activeDeviceId mismatch 直接 return - 8 種 reason → 4 種 UX (recoverable/destructive/brick 警告/contactSupport) - ContactSupport mailto handler (RFC 6068 + encodeURIComponent) Reviewer 兩輪審查: - Round 1: 0 Critical / 3 Major / 8 Minor / 5 Suggestion - Round 2: 0 Critical / 0 Major / 0 Minor / 2 Suggestion(接受方案 A、不需 frontend 第 3 輪) - MJ1 i18n namespace 採方案 A (settings.firmware.*)、Design SoT 優先、Reviewer 同意 測試: - pnpm test --run: 60 tests pass (32 firmware: 22 store + 10 badge + 新 9 error-view + 19 既有) - npx tsc --noEmit: 0 error - pnpm build: production build 成功 - go test ./internal/api/ws/... -race: 1.964s 全綠 - pnpm lint firmware/: 0 hit (17 既有 lint 問題不屬 M9-4、follow-up) 未做(範圍外): - Settings 韌體面板 (M9-12 B 階段) - 手動降版 UI (M9-12) - 版本切換 dropdown (B 階段) - Wails 控制台 force-quit modal (M9-4.5) A 階段 MVP 後端 + 前端開發全部完成、剩 M9-4.5 (SIGTERM + Wails OnBeforeClose) + M9-5 (三平台實機驗證) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5e281ed449
commit
06ff2fe987
@ -0,0 +1,211 @@
|
||||
# M9-4 Reviewer Round 2 — Frontend FW UI(第 2 輪修改驗證)
|
||||
|
||||
> 審查日期:2026-05-25
|
||||
> Reviewer:Reviewer Agent
|
||||
> 審查對象:Frontend 第 2 輪修改(i18n namespace 重組 + 3 Major + 8 Minor 修法驗證)
|
||||
> 審查層:A 層(per-task delta)
|
||||
> 第 1 輪報告:`.autoflow/05-implementation/review/m9-4-frontend-firmware-review.md`
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **第 2 輪審查結果:✅ 通過**
|
||||
- **MJ1 方案選擇:接受方案 A(namespace = `settings.firmware.*`)** — 採納 Frontend 的 pushback、Design Spec §9 v2.2 為 SoT、未做正式 Design Spec 改寫前 Reviewer 不該翻案
|
||||
- **第 1 輪 issue 修了 / 沒到位**:12/12 應修項全部到位、3 項刻意留 follow-up(Frontend 給的理由合理)、1 項 backend 不適用本輪(MJ3 屬 Backend / Testing 範圍)
|
||||
- **第 2 輪新發現**:0 Critical / 0 Major / 0 Minor / 2 Suggestion(都屬 nice-to-have、不阻擋)
|
||||
- **是否阻擋 M9-5**:否
|
||||
- **是否需 Frontend 第 3 輪**:否
|
||||
- **是否升 Security Auditor**:否(ContactSupport mailto handler 已驗證安全、無 XSS / open redirect)
|
||||
|
||||
---
|
||||
|
||||
## 方法說明
|
||||
|
||||
本輪採「delta-only」審查:不重審第 1 輪已通過細節(避免疲勞與 false positive),聚焦:
|
||||
1. 第 1 輪每條 issue 是否真的修了 / 修對方向
|
||||
2. 修改本身是否引入新問題(regression)
|
||||
3. 9 個新 FirmwareErrorView tests 品質
|
||||
|
||||
完整 5 軸 / 文件符合性 / B 層跨檔案比對等已在第 1 輪報告做過、本輪不重做。
|
||||
|
||||
---
|
||||
|
||||
## MJ1 方案選擇(Frontend pushback 評估)
|
||||
|
||||
### Frontend 主張方案 A 的理由
|
||||
|
||||
1. Design Spec v2.2 §9 是三方對齊後的正式 SoT、未做正式 Design Spec 改寫前 Frontend 不該自行翻案
|
||||
2. B 階段 M9-12 會加入 `settings.firmware` 下 §9.1 page 結構 / §9.2 device card label / §9.3 switch accordion / §9.5 downgrade modal 等更多 keys;現在 flat 結構 (`firmware.*`) 之後 M9-12 切換成 `settings.firmware.*` 會造成兩批 keys 分散維護
|
||||
|
||||
### Reviewer 評估
|
||||
|
||||
| 評估面向 | 結論 |
|
||||
|---------|------|
|
||||
| Design Spec §9 v2.2 是否正式 SoT | 是。三方 v2.2 已對齊、本輪 review 不應翻案 |
|
||||
| 方案 A 是否會與既有 i18n 結構衝突 | 不會。`settings` 是既有 namespace、`settings.firmware` 是合理子結構(types.ts:305 加註解明示「§9.1 page / §9.2 card label / §9.3 accordion / §9.5 downgrade modal 留待 M9-12 補」、forward-compatible) |
|
||||
| 方案 A vs B 的長期維護成本 | A 更低。B 階段一定要落 `settings.firmware.*`、現在做 A 等於一次到位、避免 M9-12 再 rename 一次(耗 PR + 影響 i18n test snapshot) |
|
||||
| Reviewer 第 1 輪偏好 B 的理由(commit cost 低、命名更簡潔)| 不成立。Frontend 已用 trivial rename + 更新 22 既有 test、cost 實測也很低;「命名簡潔」對單一檔案有意義、但跨 PRD/Design/i18n/code 對照查找時 namespace 對齊更值錢 |
|
||||
|
||||
**Reviewer 最終決定**:**接受方案 A**。Frontend 的 pushback 邏輯成立、Design SoT 應優先於 Reviewer 個人偏好。本輪 MJ1 視為通過。
|
||||
|
||||
---
|
||||
|
||||
## 第 1 輪 issue 修改驗證(逐項)
|
||||
|
||||
### Major(3 項)
|
||||
|
||||
| Issue | 第 1 輪要求 | 第 2 輪修法 | 驗證結果 |
|
||||
|-------|------------|------------|---------|
|
||||
| **MJ1** i18n namespace 與 §9 不一致 | 二擇一:A 搬到 `settings.firmware.*` / B 改 Design Spec | 採方案 A — `types.ts:310-375`、`zh-TW.ts:305-370`、`en.ts:305-370` 三檔同步搬入 `settings.firmware.*`;`firmware-store.ts:151-199` 8 條 errorMessageKeyFor 路徑 + 4 條 primaryActionKeyFor 路徑全改;5 個元件 (`firmware-badge / firmware-upgrade-button / firmware-upgrade-dialog / firmware-progress-view / firmware-error-view`) 所有 t() 呼叫全用新路徑;22 既有 + 9 新 firmware-store.test.ts 同步 | ✅ **完全到位**。grep `t\('firmware\.` 與 `'firmware\.`(不含 `settings.` 前綴)皆 **0 hit**、無死引用 |
|
||||
| **MJ2** FirmwareErrorView title 硬編碼中文 | 改 `t(...)` + 補 i18n key(或乾脆移除 title) | `firmware-error-view.tsx:158` 改 `title={t('settings.firmware.error.contactSupportTooltip')}`;types.ts:372 / zh-TW.ts:367 / en.ts:367 三檔都補 `contactSupportTooltip` key(中:「請聯絡技術支援」/ 英:`Please contact technical support`);同時把 disabled 改 enabled、補 `handleContactSupport` 用 mailto: 開啟(line 81-88)— 算修 MJ2 + M6 一併 | ✅ **完全到位**。額外修了 M6(disabled ContactSupport 與 UX 衝突)、合理副產品 |
|
||||
| **MJ3** Backend smoke test schema 用 `phase` 而非 `stage` | Backend / Testing 在 M9-5 階段補 | 本輪 Frontend 範圍外、未動 backend test | N/A — 不屬本輪 frontend 第 2 輪範圍、按計畫由 testing agent 於 M9-5 補 |
|
||||
|
||||
### Minor(8 項)
|
||||
|
||||
| # | 第 1 輪要求 | 第 2 輪修法 | 驗證結果 |
|
||||
|---|------------|------------|---------|
|
||||
| **M1** | 確認 1.5s modal 關閉是否與 §5.4 toast 6s 相容、加註解 | `firmware-upgrade-dialog.tsx:103-104` 註解明寫「modal 1.5s 後關閉、toast 由 showSuccess 自身 6s duration 控制」+ AC-FW-1.3 5 秒提示 | ✅ 到位 |
|
||||
| **M2** | 補 TODO 條目給 M9-12 追蹤對比 | `firmware-badge.tsx:39-41` 新增 `TODO(M9-12)` 註解明示用 axe-core 實測對比、< 4.5:1 改 component token、特別注意 amber-400 + emerald-500 暗模 | ✅ 到位 |
|
||||
| **M3** | 確認 estimatedDurationSeconds fallback 語意統一 / 加註解 | `firmware-store.ts:202-217` 整段 JSDoc 改寫「兩種 fallback 數值不同是刻意的:『不知道有沒有 type』和『有 type 但認不出』資訊量不同」;test (`firmware-store.test.ts:247-253`) 註解同步說明 | ✅ 到位(決定保留差異 + 文件化、合理) |
|
||||
| **M4** | showSuccess 預設停留是否符合 §5.4 6 秒、不足要加 duration | `lib/toast.ts` 加 `ToastOptions { duration?: number }`、4 個 toast helper 都支援;`firmware-upgrade-dialog.tsx:99` 用 `{ duration: 6000 }` 顯式傳;註解引用 Reviewer M1+M4 | ✅ 到位 |
|
||||
| **M5** | 補 FirmwareErrorView 元件渲染測試 | 新檔 `tests/components/firmware-error-view.test.tsx` 共 9 tests:destructive 3 種 brick warning + ContactSupport(it.each)/ contact mailto 點擊 / recoverable 2 種 / errorCode 顯示 / collapsible 預設收合 / Close callback | ✅ 到位(品質評估見下節)|
|
||||
| **M6** | disabled ContactSupport 改 enabled + 實際動作 | `firmware-error-view.tsx:81-88` 加 `handleContactSupport`:mailto: + subject 帶 errorCode + body 帶 technicalInfo + `target="_blank"`;line 153-162 改用 destructive variant + onClick | ✅ 到位 |
|
||||
| **M7** | 移除 device-card.tsx 與 button 元件的雙重 firmwareCanUpgrade gate | `device-card.tsx:61-64` 註解明示「由 FirmwareUpgradeButton 內部決定」、實際 JSX 直接 `<FirmwareUpgradeButton device={device} disabled={isBusy} />` 無 `device.firmwareCanUpgrade &&` 外層 gate;單一責任落地 | ✅ 到位 |
|
||||
| **M8** | 把 fetchActiveFirmwareTasks 移到專用檔 / 加註解說明刻意設計 | `firmware-store.ts:253-262` 補完整 JSDoc「刻意設計為 module-level helper、不放 store action」+ 三個理由(無 reactive state / Wails callback / 不需 subscribe);位置保留在 store 檔(Frontend 選擇文件化而非移檔) | ✅ 到位(文件化方案合理、避免 churn)|
|
||||
|
||||
### Suggestion(5 項)
|
||||
|
||||
| # | 第 1 輪建議 | 第 2 輪處置 | 驗證結果 |
|
||||
|---|------------|------------|---------|
|
||||
| **S1** | 對齊 §5.1 寬度 480px | `firmware-upgrade-dialog.tsx:157-158` 加 `className="sm:max-w-[480px]"` + 註解引用 Reviewer S1 | ✅ 修了 |
|
||||
| **S2** | M9-5 testing 階段加 stage-percent 對照測試 | 留 follow-up 給 Testing Agent M9-5 階段 | ⏸ 合理保留 — 屬 testing agent / E2E 範圍 |
|
||||
| **S3** | Dialog selector 優化 re-render | 留 follow-up — 屬 perf nice-to-have、非問題 | ⏸ 合理保留 |
|
||||
| **S4** | FirmwareUpgradeDialog integration test | 留 follow-up — 屬 Testing Agent M9-5 / M9-12 範圍 | ⏸ 合理保留 |
|
||||
| **S5** | 抽 formatTechnicalInfo helper | `firmware-error-view.tsx:23-40` 抽出 `formatTechnicalInfo(progress: FirmwareProgressEvent): string`、JSDoc 引用 Reviewer S5;handleCopy + `<pre>` + handleContactSupport 三處共用同一輸出 | ✅ 修了(且額外用在 mailto body、複用更廣)|
|
||||
|
||||
**統計**:應修 / 已修 = 12 / 12(含 MJ1 / MJ2 / 8 Minor + S1 / S5),刻意保留 3 項(S2 / S3 / S4),不適用本輪 1 項(MJ3 屬 Backend)。
|
||||
|
||||
---
|
||||
|
||||
## 9 個新 FirmwareErrorView tests 品質評估
|
||||
|
||||
檔案:`frontend/src/tests/components/firmware-error-view.test.tsx`(165 行)
|
||||
|
||||
| Test 群組 | 覆蓋 | 品質 |
|
||||
|---------|------|------|
|
||||
| destructive reasons(3 種)`it.each` brick warning + 不顯示 Retry + 顯示 ContactSupport | `disconnect_during_op / verify_mismatch / verify_not_found` | 高 — 用 `it.each` 涵蓋三種、`getByRole('note')` 驗 brick warning、`getAllByRole('button')` + label 對比驗 Retry 不存在 + ContactSupport 存在 |
|
||||
| ContactSupport mailto 點擊 | `vi.spyOn(window, 'open')` mock + `fireEvent.click` + 檢查 href schema `^mailto:` + decodeURIComponent body 含 stage / reason | 高 — 安全驗證到位(href 是 mailto: scheme 不是 http:)、技術資訊真的進 body |
|
||||
| recoverable reasons(2 種)顯示 Retry | `connect_failed` Retry 按鈕 + onRetry callback;`scan_not_found` 顯示 ReplugRetry | 高 — `queryByRole('note')` 確認沒 brick warning(negative case)、`fireEvent.click` 驗 callback |
|
||||
| errorCode 顯示 | `getAllByText(/FW_E102/).length >= 1`(同時在 `<p>` 與 `<pre>` 出現、刻意接受兩處)| 中-高 — 接受重複出現是對的、若用 `getByText` 會誤判 |
|
||||
| collapsible 預設收合 | `container.querySelector('details').open === false` | 高 — 直接驗 DOM state |
|
||||
| Close 觸發 onClose | `fireEvent.click(closeBtn) → onClose 1 次` | 高 |
|
||||
|
||||
**整體評估**:
|
||||
- ✅ 用 `it.each` 涵蓋三 destructive reason、簡潔且維護友善
|
||||
- ✅ 用 i18n label regex `/Contact|聯絡技術支援|Support/` 同時兼容中英環境、CI 切 locale 不會 fail
|
||||
- ✅ jsdom `window.open` mock 寫法正確(spyOn + mockImplementation(() => null))
|
||||
- ✅ 驗 mailto href schema 是安全把關(防止意外被改成 http: 開外部頁面)
|
||||
- ⚠️ 缺 **handleCopy** 測試(clipboard write 行為)— 第 1 輪 review 也沒列、不算缺陷、屬 nice-to-have
|
||||
- ⚠️ 缺 **techOpen toggle** 測試(onToggle 後 open=true)— 同上、屬 nice-to-have
|
||||
|
||||
**結論**:9 個 tests 品質高、達到第 1 輪 M5 要求;漏的兩項屬 nice-to-have、不阻擋本輪通過。
|
||||
|
||||
---
|
||||
|
||||
## 第 2 輪新發現
|
||||
|
||||
### Critical
|
||||
|
||||
無。
|
||||
|
||||
### Major
|
||||
|
||||
無。
|
||||
|
||||
### Minor
|
||||
|
||||
無。
|
||||
|
||||
### Suggestion
|
||||
|
||||
| # | 檔案 / 位置 | 建議 |
|
||||
|---|------------|------|
|
||||
| **R2-S1** | `firmware-error-view.tsx:81-88` `handleContactSupport` | mailto: 地址 `support@kneron.com` 硬編碼在元件內。雖然 mailto 不會被 attacker 替換(純 link)、但若日後 support email 改 / 多語系不同 region 不同信箱、會要改元件本身。可考慮把 `MAILTO_SUPPORT` 抽到 `lib/config.ts` 或讀 i18n(不過 mailto 地址通常不 i18n)。屬 nice-to-have、不阻擋。 |
|
||||
| **R2-S2** | `tests/components/firmware-error-view.test.tsx` | 缺 handleCopy 測試(clipboard 行為 + copied state 切換 2s 後 reset)+ techOpen toggle 測試。可在後續任務(M9-12 或 testing 補充階段)補。屬 nice-to-have、不阻擋本輪。 |
|
||||
|
||||
---
|
||||
|
||||
## Regression 檢查(修改本身是否引入新問題)
|
||||
|
||||
| 風險點 | 檢查結果 |
|
||||
|-------|---------|
|
||||
| **i18n namespace 重組漏 key** | grep `t\('firmware\.`(不含 settings 前綴)= 0 hit;grep `'firmware\.` 同 0 hit。所有舊路徑 100% 切完。中英對齊:types.ts:310-375 vs zh-TW.ts:305-370 vs en.ts:305-370 同 namespace 結構、所有 key 三邊都有對應(無 missed key) |
|
||||
| **ContactSupport mailto 安全性** | mailto: scheme 是 RFC 6068 standard、不會觸發 XSS(不執行 JS);target="_blank" 不引入 open redirect 風險(mailto: 由 OS 端 mail handler 處理、不會是 http:);subject + body 用 `encodeURIComponent` 包裝、即使 progress.errorCode 內含特殊字元也安全 |
|
||||
| **formatTechnicalInfo helper rawError leak** | helper 抽出後同時用在 (a) handleCopy clipboard / (b) `<pre>` 區 / (c) mailto body — 三處都是「使用者主動觸發 + 預設收合的 details / 主動點 copy / 主動點 contact」、不是被動曝露在主畫面;rawError 行只在有值時才加(line 37 `if (progress.rawError)`);無 leak 到非預期位置 |
|
||||
| **toast duration 6s 修改** | `lib/toast.ts` 改成支援 `ToastOptions`、向下相容(既有 caller 不傳 options 用 sonner 預設 4000ms);只有 firmware-upgrade-dialog.tsx:99 顯式傳 6000;不影響既有 toast 行為 |
|
||||
| **22 既有 firmware-store test 是否同步更新** | 看 firmware-store.test.ts:168-234 — 8 條 errorMessageKeyFor 直接 reason / 4 條 stage fallback / 7 條 primaryActionKeyFor 全部 expect 字串都改為 `settings.firmware.error.message.*` / `settings.firmware.error.action.*`,namespace 對齊完整 |
|
||||
| **device-card.tsx 移除外層 gate** | line 64 `<FirmwareUpgradeButton device={device} disabled={isBusy} />` 無 `device.firmwareCanUpgrade &&`;FirmwareUpgradeButton.tsx:26 內部 `if (!device.firmwareCanUpgrade) return null` 守住、不會 render;isBusy disabled 條件保留、避免 race;single responsibility 落地 |
|
||||
| **estimatedDurationSeconds 行為未變** | 函數 line 212-217 邏輯仍是 `if (!deviceType) return 60; if includes('kl720') return 180; return 30`、回傳值未變、test 三個 expect 也未變、只是 JSDoc + test 註解補充說明刻意設計 |
|
||||
|
||||
無 regression。
|
||||
|
||||
---
|
||||
|
||||
## §12.2 通用退出條件(6 條、Round 2 重檢)
|
||||
|
||||
| # | 條件 | 狀態 |
|
||||
|---|------|------|
|
||||
| 1 | No silent failures | ✅ 未觸發。`firmware-error-view.tsx:74-77` clipboard reject 靜默是 design 規格允許;新 handleContactSupport 失敗(window.open 回 null)也是 OS handler 沒 mailto 程式、不是 silent failure(mailto 已被 OS 開啟) |
|
||||
| 2 | No dead code | ✅ 未觸發。grep TODO / FIXME 只在 `firmware-badge.tsx:39 TODO(M9-12)` — 是預留 M9-12 任務追蹤、不是 dead code |
|
||||
| 3 | No hardcoded secrets | ✅ 未觸發。mailto: `support@kneron.com` 不算 secret(公開聯絡信箱);R2-S1 提到 nice-to-have 抽常數 |
|
||||
| 4 | No unsafe HTML / SQL | ✅ 未觸發。grep `dangerouslySetInnerHTML / v-html` = 0 hit;mailto subject + body 用 `encodeURIComponent` 包;React template literal 全自動 escape |
|
||||
| 5 | Doc 同步 | ✅ 觸發已解。第 1 輪 MJ1 已修、namespace 100% 對齊 Design Spec §9 |
|
||||
| 6 | Working tree clean | N/A — reviewer 自己只產 report |
|
||||
|
||||
---
|
||||
|
||||
## A 層 verification(Round 2 simplified)
|
||||
|
||||
本輪屬 delta-only review、不重做第 1 輪完整 5 軸;以下為 Round 2 必過項:
|
||||
|
||||
- [x] **R-A1(delta)**:逐項驗每條第 1 輪 issue 的修法(見「第 1 輪 issue 修改驗證」表)
|
||||
- [x] **R-A2**:i18n namespace 重組後 PRD/TDD/Design Spec §9 對齊一致(grep 0 hit 死引用 + 中英 + types 三邊對齊)
|
||||
- [x] **R-A3**:第 2 輪新發現 0 Major、2 Suggestion 都附建議
|
||||
- [x] **R-A4**:優點段落見下節
|
||||
- [x] **R-A5**:Needs investigation:0(本輪所有疑點都已決議)
|
||||
- [x] **R-A6**:§12.2 通用 6 條全跑過
|
||||
|
||||
---
|
||||
|
||||
## 優點(R-A4)
|
||||
|
||||
1. **Frontend 的 pushback 邏輯紮實**:採方案 A 並用 SoT 論證、加 forward-compatible 註解(types.ts:303-309)讓 M9-12 接手更平順、不是被動接受 reviewer 也不是無腦堅持。是好的協作姿態。
|
||||
2. **M6 「順便修」做得對**:第 1 輪只標 disabled / UX 衝突、Frontend 把它升格為實際可用按鈕(mailto + subject + body 帶 errorCode + technicalInfo);不只修「Reviewer 抓出來的」、還主動改善 UX。
|
||||
3. **formatTechnicalInfo helper 抽出後三處共用**:handleCopy / `<pre>` / handleContactSupport mailto body —— S5 不只解決 readability、實際讓「複製 vs 寄信」內容保證一致、是有實效的重構。
|
||||
4. **i18n duration: 6000 配 §5.4** + lib/toast.ts 補 ToastOptions API:解 M4 同時把 toast util 介面 forward-compatible(未來其他 toast 也能調 duration),不是只 patch firmware-upgrade-dialog 一處。
|
||||
5. **9 個新 FirmwareErrorView tests 涵蓋完整**:mailto href schema 驗證(`^mailto:`)+ body decodeURIComponent 內容驗證、是測試到「行為 + 安全」兩面、不是只測 render。
|
||||
6. **JSDoc 引用 Reviewer ticket 編號**:`(Reviewer S5)` / `(Reviewer M1+M4)` / `(Reviewer S1)` / `(M3 釐清)` — 修改可追溯回 review 條目、未來看 code 知道為什麼這樣寫。
|
||||
7. **M3 處置成熟**:沒急著統一 fallback 數值(避免行為 regression)、改用 JSDoc + test 註解雙重文件化「刻意設計」;是「文件化 over 程式變更」的好範例。
|
||||
|
||||
---
|
||||
|
||||
## 結論
|
||||
|
||||
**Round 2 審查結果:✅ 通過、無需 Frontend 第 3 輪**
|
||||
|
||||
第 1 輪 12 項應修 issue 全部到位、3 項刻意 follow-up 理由合理、1 項 (MJ3) 屬 Backend 範圍按計畫外延。MJ1 採方案 A 是正確選擇、Frontend pushback 邏輯成立、Reviewer 接受。第 2 輪新發現僅 2 個 Suggestion、都屬 nice-to-have、不阻擋。
|
||||
|
||||
**是否阻擋 M9-5**:否。本輪完全不阻擋 M9-5 三平台 KL520+KL720 實機 E2E。
|
||||
|
||||
**是否需 Frontend 第 3 輪**:否。除非 M9-5 階段 testing agent 發現新 issue、否則 M9-4 frontend 部分本輪結案。
|
||||
|
||||
**剩餘待 follow-up**(非阻擋):
|
||||
- MJ3:Backend smoke test schema、由 Testing Agent / Backend Agent 在 M9-5 階段補
|
||||
- S2:stage-percent 對照測試、Testing Agent M9-5
|
||||
- S3:Dialog selector 優化、perf nice-to-have
|
||||
- S4:FirmwareUpgradeDialog integration test、Testing M9-5 / M9-12
|
||||
- R2-S1:mailto 地址抽常數、nice-to-have
|
||||
- R2-S2:handleCopy / techOpen toggle 測試、nice-to-have
|
||||
|
||||
**是否升 Security Auditor**:否。ContactSupport mailto handler 已驗證安全(RFC 6068 scheme + encodeURIComponent + 不引入 XSS / open redirect / hardcoded secret)。
|
||||
@ -0,0 +1,466 @@
|
||||
# Reviewer Report — M9-4 Frontend FW UI + Backend WS hot-fix
|
||||
|
||||
> 審查日期:2026-05-25
|
||||
> Reviewer:Reviewer Agent
|
||||
> 審查對象:M9-4 Frontend FW UI(12 新 + 4 修改 = 16 檔、3052 行)+ Backend WS hot-fix(2 新 + 1 修改)
|
||||
> 審查層:A 層(per-task)+ B 層(多檔案 PR、18 檔 / >500 行)
|
||||
> 依據文件:Design Spec v2/firmware-management.md(948 行)/ PRD feature-firmware-management.md / TDD v2/firmware-management.md
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **審查結果:⚠️ 需修改後通過**
|
||||
- **Critical:0 | Major:3 | Minor:8 | Suggestion:5**
|
||||
- **是否阻擋 M9-5:否**(3 個 Major 都是 UX/i18n/test schema 層級、不阻擋三平台實機 E2E;M9-5 可平行開跑、Frontend 第 2 輪修這 3 項)
|
||||
- **是否升 security agent:否**(5 軸 security 粗篩無發現、`navigator.clipboard?.writeText` 已守、無 XSS / hardcoded secret、loopback origin 由 backend 既有 `CheckOrigin` 共用)
|
||||
- **是否需 frontend 第 2 輪**:是(3 個 Major + 部分 Minor 修完即可)
|
||||
- **是否需 backend 第 2 輪**:否(hot-fix 對稱 flash_ws.go、smoke test 通過、Minor M5 屬 testing agent 範圍、可在 M9-5 testing 階段補)
|
||||
|
||||
---
|
||||
|
||||
## 審查範圍
|
||||
|
||||
| # | 檔案 | 行數 | 類別 |
|
||||
|---|------|------|------|
|
||||
| 1 | `frontend/src/types/device.ts` | +62 | Frontend / Types |
|
||||
| 2 | `frontend/src/stores/firmware-store.ts` | 262(新) | Frontend / Store |
|
||||
| 3 | `frontend/src/hooks/use-firmware-progress.ts` | 81(新) | Frontend / Hook |
|
||||
| 4 | `frontend/src/components/firmware/firmware-badge.tsx` | 95(新) | Frontend / Component |
|
||||
| 5 | `frontend/src/components/firmware/firmware-upgrade-button.tsx` | 51(新) | Frontend / Component |
|
||||
| 6 | `frontend/src/components/firmware/firmware-upgrade-dialog.tsx` | 227(新) | Frontend / Component |
|
||||
| 7 | `frontend/src/components/firmware/firmware-progress-view.tsx` | 109(新) | Frontend / Component |
|
||||
| 8 | `frontend/src/components/firmware/firmware-error-view.tsx` | 150(新) | Frontend / Component |
|
||||
| 9 | `frontend/src/components/firmware/index.ts` | 10(新) | Frontend / Barrel |
|
||||
| 10 | `frontend/src/components/devices/device-card.tsx` | +X | Frontend / Modified |
|
||||
| 11 | `frontend/src/lib/i18n/types.ts` | +71 | Frontend / i18n |
|
||||
| 12 | `frontend/src/lib/i18n/zh-TW.ts` | +68 | Frontend / i18n |
|
||||
| 13 | `frontend/src/lib/i18n/en.ts` | +68 | Frontend / i18n |
|
||||
| 14 | `frontend/src/tests/stores/firmware-store.test.ts` | 265(新、22 tests)| Frontend / Test |
|
||||
| 15 | `frontend/src/tests/components/firmware-badge.test.tsx` | 98(新、10 tests)| Frontend / Test |
|
||||
| 16 | `server/internal/api/ws/firmware_ws.go` | 50(新) | Backend / Hot-fix |
|
||||
| 17 | `server/internal/api/ws/firmware_ws_test.go` | 165(新、2 tests) | Backend / Test |
|
||||
| 18 | `server/internal/api/router.go` | +2 | Backend / Modified |
|
||||
|
||||
**漏審檢查(R-B2)**:18 檔全部覆蓋、無漏審。
|
||||
|
||||
---
|
||||
|
||||
## 文件符合性檢查(R-A2)
|
||||
|
||||
### PRD 功能符合性
|
||||
|
||||
| PRD AC 項目 | 是否實作 | 實作位置 | 符合程度 | 備註 |
|
||||
|------------|---------|----------|---------|------|
|
||||
| AC-FW-1.1 FW badge 4 色(綠 / 黃 / 紅 / 灰)| ✅ | `firmware-badge.tsx:20-30` `computeBadgeState` | 完全 | legacy/unknown/current/older 四 state、優先序合理 |
|
||||
| AC-FW-1.1 衍生欄位(`firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion`)| ✅ | `types/device.ts:11-14` | 完全 | 與 TDD §3.1 一致 |
|
||||
| AC-FW-1.2 升級按鈕(黃 / 紅 badge 條件)| ✅ | `firmware-upgrade-button.tsx:26` 用 `firmwareCanUpgrade` gate | 完全 | 條件由 backend 衍生欄位決定、Frontend 不自己 derive |
|
||||
| AC-FW-1.2 progress modal stage 5-6(含 preparing/loading/flashing/verifying)| ✅ | `firmware-progress-view.tsx:42-57` | 完全 | 4 stage + done 對齊 §4.3 |
|
||||
| AC-FW-1.2 4 階段 / 3 階段 split(KDP1→KDP2 vs KDP2→KDP2)| ✅ | `firmware-store.ts:213-243` `stageOrdinal` | 完全 | `isLegacyUpgrade` 切 4 vs 3 stage、跟 Design §5.3 對齊 |
|
||||
| AC-FW-1.2 不可中斷警告 | ✅ | `firmware-upgrade-dialog.tsx:147-160` 拒絕 onOpenChange/ESC/outside-click | 完全 | R-FW-11 緩解、ESC + interactOutside 都防 |
|
||||
| AC-FW-1.3 升級成功 toast + rescan | ✅ | `firmware-upgrade-dialog.tsx:85-107` | 完全 | success effect → showSuccess + fetchDevices + 1.5s 後關閉 |
|
||||
| AC-FW-1.4 8 種 reason 對應 UI | ✅ | `firmware-store.ts:150-200` 三個 helper | 完全 | `errorMessageKeyFor` / `canRetryReason` / `primaryActionKeyFor` 三個 pure helper |
|
||||
| AC-FW-1.4 「複製錯誤訊息」按鈕 | ✅ | `firmware-error-view.tsx:120-131` | 完全 | clipboard fallback 靜默 OK |
|
||||
| AC-FW-1.4 技術資訊 collapsible | ✅ | `firmware-error-view.tsx:101-118` `<details>` 元素 | 完全 | rawError 在 collapsible 內、不在主畫面 |
|
||||
| AC-FW-1.5 升級期間鎖住其他操作 | ✅ | `device-card.tsx:63-65` `disabled={isBusy}` | 完全 | 由 device-store 既有 `isBusy` 機制涵蓋 |
|
||||
| AC-FW-1.6 升級後 rescan | ✅ | `firmware-upgrade-dialog.tsx:100` `fetchDevices()` | 完全 | 但 5-8s wait 在 backend 處理、frontend 不需 |
|
||||
| AC-FW-1.7 KL520 ~30s / KL720 ~180s 預估 | ✅ | `firmware-store.ts:206-211` `estimatedDurationSeconds` | 完全 | chip 分流邏輯正確 |
|
||||
| AC-FW-2 降版 UI(B 階段 M9-12)| N/A | — | — | 本批不在範圍 |
|
||||
| AC-FW-3.5 KL630/KL730 升降版 | N/A | — | — | B 階段 M9-10、本批不在範圍 |
|
||||
|
||||
**PRD 功能完整度**:M9-4 範圍內 14/14 條 AC 全部實作、無遺漏。
|
||||
|
||||
### TDD 技術規格符合性
|
||||
|
||||
| TDD 規格項目 | 是否符合 | 實作位置 | 備註 |
|
||||
|------------|---------|----------|------|
|
||||
| §3.1 衍生欄位 `firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion` | ✅ | `types/device.ts:11-14` | 與 backend 一致 |
|
||||
| §3.4 stage → reason → i18n key 對應表(8 種)| ✅ | `firmware-store.ts:150-172` + i18n keys | 完全 mapping、附 stage-only fallback |
|
||||
| §4.2 `FirmwareProgress` schema | ✅ | `types/device.ts:57-75` | json field 對應 backend struct(已驗 `server/internal/firmware/types.go:34-53` 完全對齊:`elapsedMs/etaMs/rawError/beforeVersion/afterVersion/errorCode/reason`)|
|
||||
| §4.3 stage 命名(`preparing/loading/flashing/verifying/done/error`)| ✅ | `types/device.ts:35-41` | 完全對齊 |
|
||||
| §4.2 失敗欄位(`reason/rawError/beforeVersion/errorCode`)| ✅ | `types/device.ts:67-72` | 完全 |
|
||||
| §5.1 流程:先 connectAndWait → 再 POST API | ✅ | `firmware-upgrade-dialog.tsx:109-130` `handleStart` | 順序對、避免漏 event |
|
||||
| WS endpoint `/ws/devices/:id/firmware-progress` | ✅ | `use-firmware-progress.ts:52` + `router.go:125` + `firmware_ws.go:30` | 三端命名一致 |
|
||||
| WS room key `firmware:<deviceID>` | ✅ | `firmware_ws.go:30` + `firmware_handler.go:174` | 完全 |
|
||||
| §8.6 Wails graceful shutdown — `GET /api/firmware/active-tasks` | ✅ | `firmware-store.ts:252-262` `fetchActiveFirmwareTasks` | A 階段預埋、實際 UI 由 Wails / control-panel M9-12 接 |
|
||||
|
||||
**TDD 規格符合度**:9/9 項全部符合。
|
||||
|
||||
### 設計規格符合性
|
||||
|
||||
| Design Spec 項目 | 是否符合 | 實作位置 | 備註 |
|
||||
|----------------|---------|----------|------|
|
||||
| §3.4 stage 命名(preparing/loading/flashing/verifying/done/error)| ✅ | `firmware-store.ts:36-41` | 完全 |
|
||||
| §4.2 Badge 規格(pill / radius.full / 11px medium)| ✅ | `firmware-badge.tsx:86` `rounded-full px-2 py-0.5 text-xs font-medium` | 視覺對齊 |
|
||||
| §4.2 Badge 顏色(綠 success / 黃 warning / 紅 destructive)| ⚠️ 部分 | `firmware-badge.tsx:38-43` 用 emerald/amber + semantic destructive/muted | 見 M2 — 暫代 Tailwind palette、Design §11.2 明示 6 個 component token 由 M9-12 落地、本批合理 |
|
||||
| §5.1 升級確認 modal 寬度 480px | ⚠️ 部分 | 沿用 shadcn `Dialog` 預設 max-w-lg | 見 Suggestion S1 — 視覺檢查時可微調 |
|
||||
| §5.2 升級進度 modal 不顯示 ✕ / 取消按鈕 | ✅ | `firmware-upgrade-dialog.tsx:154` `showCloseButton={!isInProgress}` | R-FW-11 緩解 |
|
||||
| §5.3 stage 對應表(preparing=5% / loading=20% / flashing=50% / verifying=90% / done=100%)| ⚠️ | UI 信任 backend `progress.percent`、不 hardcode 數值 | 見 Suggestion S2 — 信任 backend 是對的、但 backend 是否真按表發 percent 是另一個 audit 項 |
|
||||
| §5.4 成功 toast variant + 6s 停留 | ⚠️ | 用既有 `showSuccess`(既有 toast、停留時間未確認)| 見 Minor M4 |
|
||||
| §6 降版 UI(B 階段)| N/A | — | 不在範圍 |
|
||||
| §7.1 8 種 reason → friendly message | ✅ | `firmware-store.ts:errorMessageKeyFor` + i18n 8 個 key | 完全 mapping |
|
||||
| §7.1 destructive reason 不提供 Retry | ✅ | `firmware-store.ts:179-186` + `firmware-error-view.tsx:91-98, 142-146` | `disconnect_during_op / verify_mismatch / verify_not_found` 三種不可 retry |
|
||||
| §7.2 失敗 modal 含技術資訊 collapsible | ✅ | `firmware-error-view.tsx:101-118` | 對齊 |
|
||||
| §7.2 技術資訊預設收合 | ✅ | `firmware-error-view.tsx:36` `useState(false)` | 對齊 |
|
||||
| §9 i18n keys 51 個 + zh-TW / en 雙語 | ⚠️ 部分 | i18n 結構不照 §9 namespace(用 `firmware.error.messageXxx` flat 結構 vs Design 的 `settings.firmware.error.message.xxx` 巢狀)| 見 Major MJ1 — namespace 與 Design Spec §9 不同 |
|
||||
| §11.2 對比比率(current 4.7:1 / older 5.2:1 / legacy 4.8:1)| ⚠️ 部分 | 暫代 Tailwind palette、註解明示 M9-12 落地正式 token | 由 M9-12 Frontend 用 axe-core 實測(Design §11.2 備註明示)|
|
||||
| §12.1 R-FW-11 緩解(不可關 modal)| ✅ | `firmware-upgrade-dialog.tsx:146-160` 三層擋(onOpenChange / interactOutside / EscapeKeyDown)| 完全 |
|
||||
| §12.2 R-FW-11.5(嚴格 `===` 比對「DOWNGRADE」)| N/A | — | 降版 B 階段、本批不適用 |
|
||||
| §10 A11y(role / aria-label / aria-live)| ✅ | `firmware-progress-view.tsx:69-71, 84-88` + `firmware-error-view.tsx:74, 78` | `role="status" aria-live="polite"` 在進度區、`role="alertdialog"` 在 error modal、合理 |
|
||||
|
||||
**設計規格符合度**:13/16 項完全符合、3 項部分符合(其中 MJ1 是 Major、其餘是合理的暫代 / 範圍外)。
|
||||
|
||||
---
|
||||
|
||||
## 5 軸 + 測試軸審查(R-A1)
|
||||
|
||||
### 3.1 Correctness(正確性)
|
||||
|
||||
實質判斷(≥20 字):實作正確性高、phase state machine 嚴謹、多裝置隔離(`activeDeviceId` mismatch return)對齊 flash-store M4 fix、WS connectAndWait 與 POST API 順序正確避免漏早期 event、destructive reason 不提供 Retry 安全防護到位、clipboard fallback 靜默 OK。發現的問題集中在 i18n namespace 對齊與測試 schema 偏差(見 Major / Minor)。
|
||||
|
||||
具體 finding:
|
||||
- ✅ `firmware-store.ts:110-115` 多裝置隔離邏輯正確(`if (active !== null && active !== ev.deviceId) return`)
|
||||
- ✅ `firmware-upgrade-dialog.tsx:109-130` `connectAndWait` 在 POST 之前、避免 WS 還沒 open 漏掉 backend 早期 event(對齊 flash-store pattern)
|
||||
- ⚠️ `firmware-store.ts:248-249` test case `expect(estimatedDurationSeconds('unknown_device')).toBe(30)` 與函數實作不一致 — 函數 line 207-211 `if (!deviceType) return 60`,但 `'unknown_device'` 是 truthy 字串會走到 `if (low.includes('kl720')) return 180` → false → fallback return 30(KL520 路徑);測試正確、註解標「預設走 KL520 路徑」、但 line 247-249 同時測 `undefined → 60` 和 `'unknown_device' → 30` 邏輯不一致(一個沒 type 走 60、有 type 但不認得走 30)。語意不直覺、但兩個 case 都通過、屬 minor 表達問題(見 Minor M3)
|
||||
|
||||
### 3.2 Readability(可讀性)
|
||||
|
||||
實質判斷:命名清晰、註解充足(每個元件頂部有 phase / 對齊文件條目註解)、TypeScript 型別嚴格(沒 `any`)、phase 機(confirming → upgrading → success / error)邏輯線性、helpers 從 store 分離為 pure functions(便於測試與 M9-12 重用)。控制流可讀、最深巢狀 `firmware-error-view.tsx:110-117` 的 template literal 較長但語意清楚。
|
||||
|
||||
具體 finding:
|
||||
- ✅ `firmware-store.ts:147-243` pure helpers 區塊分離(無 React 依賴)
|
||||
- ✅ 每個元件首段 JSDoc 都引用 Design / TDD 條目(追溯性強)
|
||||
- ⚠️ `firmware-error-view.tsx:109-117` 技術資訊 template literal 較複雜(多層 `${cond ? a : ''}`)、可讀但不易調整(見 Suggestion S5)
|
||||
|
||||
### 3.3 Architecture(架構)
|
||||
|
||||
實質判斷:架構正確遵循既有 pattern — Zustand store + React hook + 純元件分離、與 flash-store 結構對稱、useFirmwareProgress 100% mimic useFlashProgress;backend hot-fix 對稱 flash_ws.go(同 upgrader、同 CheckOrigin、同 RegisterSync、同 read pump pattern)。Activity store reuse `flash_*` event types 屬合理跨領域共用(兩者語意都是 device 進行中操作)、雖然 type literal 字面是 `flash_*` 但語意通用。
|
||||
|
||||
具體 finding:
|
||||
- ✅ `firmware-store.ts:102-106, 121, 127-129` reuse `flash_start / flash_complete / flash_error` activity types — 合理權衡、避免 activity 型別爆炸
|
||||
- ✅ `firmware_ws.go` 與 `flash_ws.go` 結構幾乎 1:1 對稱、易維護
|
||||
- ✅ 純 helpers 從 store 分離(`errorMessageKeyFor / canRetryReason / primaryActionKeyFor / estimatedDurationSeconds / stageOrdinal`)方便 M9-12 重用、且測試友善
|
||||
|
||||
### 3.4 Security(粗篩)
|
||||
|
||||
實質判斷:**5 軸 security 粗篩無發現、不升級給 Security Auditor**。
|
||||
|
||||
- ✅ **未 sanitize 的 HTML**:`firmware-error-view.tsx:108-118` 用 `<pre>{...template literal}` 渲染、React 自動 escape、無 dangerouslySetInnerHTML(已 grep `dangerouslySetInnerHTML / v-html`:零 hit)
|
||||
- ✅ **raw error 不在主畫面**:rawError 只出現在 collapsible `<details>` 內(line 109-117)、預設收合(line 36)、Design §7.2 規格符合
|
||||
- ✅ **clipboard 操作**:`firmware-error-view.tsx:61-70` 用 `navigator.clipboard?.writeText` optional chain 守、reject 路徑靜默不洩漏
|
||||
- ✅ **WS Origin check**:backend `firmware_ws.go:23` 共用 package-level upgrader、`device_events_ws.go:14` 設定 `CheckOrigin: CheckOrigin`(loopback 白名單、`origin.go:18-40`)、Frontend 連 `/ws/devices/:id/firmware-progress` 與 backend 一致
|
||||
- ✅ **無 hardcoded secret / token**:grep `password / secret / api_key / token`:零 hit(不算 `taskId / activeTaskId` 內部命名)
|
||||
- ✅ **無 SQL 拼接**:本批無 SQL(純 WS + frontend state)
|
||||
- ✅ **無 OAuth / 第三方 SDK 新增**:純內部 WS + REST
|
||||
|
||||
### 3.5 Performance(效能)
|
||||
|
||||
實質判斷:性能合理、無明顯瓶頸。
|
||||
|
||||
- ✅ Badge 在每張 card render 是 O(N) cost、`computeBadgeState` 是 pure function、無重 React.memo 需求(N 通常 < 10 dongles)
|
||||
- ✅ Progress modal re-render 頻率受 WS event 控制、backend 每階段 push(typically < 5 events / 30-180s)、不會頻繁 re-render
|
||||
- ✅ Zustand subscription 範圍合理、`useFirmwareStore()` 整 hook 拿、無 selector micro-optimization 必要(state 本身小、且 phase 變化即整個 modal 切換)
|
||||
- ⚠️ `firmware-upgrade-dialog.tsx:45-55` 用 `useFirmwareStore()` 整個拿、不 selector — 一般 OK、但 progress event 每秒推時整個 dialog 重 render(含 confirming phase 的 UI 樹);非問題、屬可接受 trade-off(見 Suggestion S3)
|
||||
|
||||
### 3.6 測試(測試軸)
|
||||
|
||||
實質判斷:測試覆蓋度高 — 22 + 10 = 32 個 frontend tests、覆蓋 phase 轉移 / 多裝置隔離 / cancelConfirm R-FW-11 緩解 / 8 種 reason mapping / 4-stage vs 3-stage / a11y attribute。Backend 2 個 smoke test 驗 broadcast + room isolation、合理。
|
||||
|
||||
具體 finding:
|
||||
- ✅ 多裝置隔離測試(`firmware-store.test.ts:123-138`)— 直接模擬 dev-A active + dev-B event、驗 dev-B 不污染、品質高
|
||||
- ✅ R-FW-11 緩解測試(`firmware-store.test.ts:58-67`)— 驗 `cancelConfirm` 在 `upgrading` phase 是 no-op
|
||||
- ✅ destructive reason 不可 retry 測試(`firmware-store.test.ts:201-219`)— 3 種 destructive + 5 種 recoverable + undefined 都涵蓋
|
||||
- ⚠️ 缺 `useFirmwareProgress` hook 的測試(WS connectAndWait timeout / cleanup)— 既有 `useFlashProgress` 也沒測(屬既有不嚴格 pattern)、可加但不阻擋
|
||||
- ⚠️ `firmware_ws_test.go:62-67` 的測試 message schema 用 `phase: "flashing"` 是 ad-hoc map(不是真的 firmwareProgressMessage struct)、smoke test 只驗 plumbing 不驗 schema → 見 Major MJ3
|
||||
- ⚠️ 缺 `FirmwareErrorView` 元件渲染測試(destructive reason 顯示 brick warning / Retry 按鈕隱藏)— 見 Minor M5
|
||||
- ⚠️ 缺 `FirmwareUpgradeDialog` integration 測試(phase 切換時 modal 視覺與 R-FW-11 擋 ESC 行為)— 見 Suggestion S4
|
||||
|
||||
---
|
||||
|
||||
## §12.2 通用退出條件(6 條)
|
||||
|
||||
| # | 條件 | 觸發狀態 |
|
||||
|---|------|---------|
|
||||
| 1 | No silent failures | ✅ 未觸發:`firmware-store.ts:86-104` startUpgrade 失敗有 catch + addActivity 紀錄、`firmware-upgrade-dialog.tsx:112-128` connectAndWait 失敗有 catch + 進 error phase、`firmware-error-view.tsx:65-69` clipboard reject 是有意靜默(design 規格允許) |
|
||||
| 2 | No dead code | ✅ 未觸發:grep TODO / FIXME / commented-out code、本批 0 hit;所有 import 都被使用 |
|
||||
| 3 | No hardcoded secrets | ✅ 未觸發 |
|
||||
| 4 | No unsafe HTML / SQL | ✅ 未觸發(見 3.4 security 軸) |
|
||||
| 5 | Doc 同步 | ⚠️ **觸發**:i18n key namespace 與 Design Spec §9 不一致(見 MJ1) |
|
||||
| 6 | Working tree clean | N/A:reviewer 自己只產 report、被審 commit 由 backend / frontend agent 負責、`git status` 不在 reviewer 範圍 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical
|
||||
|
||||
**無 Critical 問題。**
|
||||
|
||||
---
|
||||
|
||||
## 🟠 Major(必須修復)
|
||||
|
||||
### MJ1:i18n key namespace 與 Design Spec §9 不一致
|
||||
|
||||
| 欄位 | 內容 |
|
||||
|------|------|
|
||||
| 檔案 | `frontend/src/lib/i18n/types.ts:140-198`、`zh-TW.ts:139-197`、`en.ts:139-197` |
|
||||
| 規則 | Doc 同步 / Design Spec §9 對齊 |
|
||||
| 違規程度 | Major(影響 M9-12 Settings 韌體面板 i18n 對齊 + Design Spec source-of-truth 失效) |
|
||||
| 對應文件 | Design Spec §9.6 / §9.8 |
|
||||
| 問題描述 | Design Spec §9.6 定義 `settings.firmware.progress.stage.preparing` 等 51 個 keys,全部住在 `settings.firmware.*` namespace 下;Frontend 實作改用 `firmware.error.messageScanNotFound` / `firmware.progress.stagePreparing` 等 flat 結構、住在頂層 `firmware.*` namespace。 |
|
||||
| 影響 | (1) M9-12 Settings 韌體面板(B 階段)落地時、若照 Design §9 規格用 `settings.firmware.*` 會與本批 key 衝突或產生重複;(2) Design Spec §9 是 source of truth、未來文件 ↔ code 對照查 key 時找不到;(3) 51 keys vs 實際 ~28 keys 差距大(Design §9 含 settings 分頁 label / accordion / downgrade modal 等 B 階段 keys、A 階段確實不需全 51 個、但本批產的 keys 命名規則應對齊 Design namespace) |
|
||||
| 建議修改方式 | 兩個方案二選一(讓 Orchestrator 與 Design 共同裁決):<br>**A.** 把 i18n keys 搬到 `settings.firmware.*` namespace 下(雖然 A 階段不在 settings 頁、但對齊 Design 文件):例如 `firmware.error.messageScanNotFound` → `settings.firmware.error.message.scanNotFound`<br>**B.** 維持 `firmware.*` flat 結構,但回頭請 Design Agent 修 Design Spec §9 把 namespace 改為與本批一致,明確說明「i18n key 與分頁位置解耦」<br>**Reviewer 偏好**:B(commit cost 低、命名更簡潔)、但需 Design 明示同意 |
|
||||
|
||||
### MJ2:FirmwareErrorView 的 `actionContactSupport` 按鈕 `title` 屬性硬編碼中文(i18n leak)
|
||||
|
||||
| 欄位 | 內容 |
|
||||
|------|------|
|
||||
| 檔案 | `frontend/src/components/firmware/firmware-error-view.tsx:143` |
|
||||
| 規則 | i18n 完整性 / no hardcoded user-facing string |
|
||||
| 違規程度 | Major(影響英文版 UX、a11y tooltip) |
|
||||
| 對應文件 | Design Spec §7.2 + i18n 完整性原則 |
|
||||
| 問題描述 | `<Button ... disabled title="請聯絡技術支援">` — `title` 屬性是 user-visible tooltip、但直接寫死中文字串,沒走 `t()`;英文用戶 hover 會看到中文。 |
|
||||
| 影響 | en 用戶看到中文 tooltip;a11y screen reader 在某些設定下也會讀 title。 |
|
||||
| 建議修改方式 | `title={t('firmware.error.actionContactSupportTooltip')}`,並在 i18n types.ts / zh-TW.ts / en.ts 補一條 `actionContactSupportTooltip` key(zh-TW: 「請聯絡技術支援」/ en: `Please contact technical support`)。或乾脆移除 `title`(按鈕本身已有 text label `actionContactSupport`、重複) |
|
||||
|
||||
### MJ3:Backend smoke test 用 `phase` field 而非 production schema 的 `stage`
|
||||
|
||||
| 欄位 | 內容 |
|
||||
|------|------|
|
||||
| 檔案 | `server/internal/api/ws/firmware_ws_test.go:62-67` |
|
||||
| 規則 | Test 應反映 production schema(即使是 smoke test)|
|
||||
| 違規程度 | Major(test 通過但不驗 schema、未來 schema 改 test 不會 fail) |
|
||||
| 對應文件 | TDD §4.2 `FirmwareProgress.Stage` |
|
||||
| 問題描述 | 測試 broadcast 用 `map[string]interface{}{"type": "firmware:progress", "deviceId": ..., "phase": "flashing", "percent": 42}` — 但 production handler `firmwareProgressMessage` embed `firmware.FirmwareProgress`、json field 是 `stage`(見 `server/internal/firmware/types.go:36`)、不是 `phase`。同時 production `type` 值是 `"firmware_progress"`(見 `firmware_handler.go:180`、底線而非冒號)、測試用 `"firmware:progress"`(冒號)也不對。 |
|
||||
| 影響 | (1) Test 把錯誤 schema 通過,未來真有 backend handler bug 推 `phase` 而非 `stage` 不會被測抓到;(2) Frontend `useFirmwareProgress` 解析 `ev.stage` —— 如果哪天 backend 因某種重構真的改 schema,frontend 會 silently break、smoke test 仍 pass。 |
|
||||
| 建議修改方式 | 改用真的 `firmware.FirmwareProgress` struct 或對齊 production 的 `firmwareProgressMessage` schema:<br>```go<br>hub.BroadcastToRoom(room, map[string]interface{}{<br> "type": "firmware_progress", // 底線、對齊 firmware_handler.go:180<br> "deviceId": deviceID,<br> "stage": "flashing", // 不是 phase<br> "percent": 42,<br> "elapsedMs": int64(5000),<br>})<br>```<br>並把 assertion `got["phase"] != "flashing"` 改為 `got["stage"] != "flashing"`、`got["type"] != "firmware:progress"` 改為 `got["type"] != "firmware_progress"`。 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Minor(建議修復)
|
||||
|
||||
| # | 檔案 | 行數 | 問題描述 | 建議修改方式 |
|
||||
|---|------|------|----------|-------------|
|
||||
| M1 | `firmware-upgrade-dialog.tsx` | 102-104 | `setTimeout(() => onOpenChange(false), 1500)` 與 Design §5.4 提到 toast 停留 6 秒不一致;AC-FW-1.3 提到 5 秒、註解寫 1.5s「給 toast 過渡」。實作偏向更積極關閉、可能讓使用者來不及讀 toast。 | 與 Design 確認 1.5s 是否合理(toast 還會繼續顯示 6 秒、modal 早關不影響)、若 OK 在註解明確寫「modal 1.5s 關閉、toast 由 `showSuccess` 自身控制停留」 |
|
||||
| M2 | `firmware-badge.tsx` | 38-43 | Tailwind palette 直貼(`bg-emerald-600 / bg-amber-500 / dark:bg-emerald-500 / dark:bg-amber-400`)、Dark mode 對比實測責任在 M9-12(Design §11.2 備註明示)。本批 OK、但缺一個 TODO 條目供 M9-12 追蹤。 | 在 `firmware-badge.tsx:32-37` 註解區補一條 `// TODO(M9-12): 用 axe-core 實測對比、若 < 4.5:1 → 改 component token`、或寫進 progress.md「未解決問題」 |
|
||||
| M3 | `firmware-store.test.ts` | 248-249 | 同一 `describe` 內測 `undefined → 60` 和 `'unknown_device' → 30`、語意不直覺(一個沒 type 走 60、有 type 但不認得走 30)。雖然測試正確、但 fallback 邏輯本身應該統一(要嘛全 fallback 60、要嘛全 fallback 30) | 確認 `firmware-store.ts:206-211` `estimatedDurationSeconds` 意圖:若 `!deviceType` 是「不知 chip 給保守值 60」、`unknown_device` 是「認不出 chip 給 KL520 預設」、邏輯可以接受但建議加註解;或乾脆統一 fallback 為 60 |
|
||||
| M4 | `firmware-upgrade-dialog.tsx` | 90-98 | `showSuccess()` 停留時間未確認是否符合 Design §5.4「6 秒」規格 | 查 `lib/toast` 既有 `showSuccess` 預設 duration、若 < 6 秒考慮加 `showSuccess(..., { duration: 6000 })` 或在 toast util 層補 |
|
||||
| M5 | testing | — | 缺 `FirmwareErrorView` 元件渲染測試(destructive reason 顯示 brick warning / Retry 按鈕隱藏 / disabled ContactSupport 按鈕) | 加 3-5 個 RTL 測試覆蓋 destructive vs recoverable reason 的 UI 差異(屬 Testing Agent 範圍、可在 M9-5 補) |
|
||||
| M6 | `firmware-error-view.tsx` | 142-146 | `disabled` ContactSupport 按鈕無實際動作、只是視覺占位、與 destructive UX 衝突(按鈕本意是「請聯絡技術支援」、disabled 反而傳達「不能聯絡」) | 兩個方案:(a) 改 enabled、`onClick={() => window.open('mailto:support@...')}` 或開既有 support 文件;(b) 改 plain `<p>` 不用 Button;建議 (a)、由 Design Agent 提供具體 support 入口 |
|
||||
| M7 | `firmware-upgrade-button.tsx` | 26 | `if (!device.firmwareCanUpgrade) return null` 在元件內 early return,但 `device-card.tsx:63` 也已用 `device.firmwareCanUpgrade &&` gate;雙重檢查、無害但冗餘 | 移除 `device-card.tsx:63` 的 `device.firmwareCanUpgrade &&`、讓 button 元件單一決定渲染(更符合 single responsibility);或反之 |
|
||||
| M8 | `firmware-store.ts` | 252-262 | `fetchActiveFirmwareTasks` 是 module-level export(不在 store action 內)、命名 `useFirmwareStore` 一族但這個是 helper; | 移到 `lib/api/firmware.ts` 或 `lib/firmware-active-tasks.ts` 等專用檔;或在註解明確寫「此函數刻意不放 store 因為它是 Wails 端 callback 用、無 reactive state」 |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Suggestion(非必要)
|
||||
|
||||
| # | 檔案 | 行數 | 建議內容 |
|
||||
|---|------|------|----------|
|
||||
| S1 | `firmware-upgrade-dialog.tsx` | DialogContent | Design §5.1 規格寫 480px,shadcn Dialog 預設 `max-w-lg`(512px)、未顯式設寬度;視覺檢查時可加 `className="max-w-[480px]"` 對齊 |
|
||||
| S2 | TDD §5.3 stage % 對照 | — | 本批信任 backend `progress.percent` 不 hardcode 是對的、但 reviewer 沒驗 backend 是否真按 §5.3 表發 percent(5/20/50/90/100)。建議 M9-5 testing 階段加 stage-percent 對照測試 |
|
||||
| S3 | `firmware-upgrade-dialog.tsx` | 45-55 | 用 `useFirmwareStore()` 整 hook 拿、不 selector — progress event 每秒推時整個 dialog 重 render(含 confirming UI tree);非問題、屬可接受 trade-off;若優化可拆 selector |
|
||||
| S4 | testing | — | 缺 `FirmwareUpgradeDialog` integration 測試(phase 切換時 modal 視覺與 R-FW-11 擋 ESC 行為)。屬 Testing Agent 範圍、可在 M9-5 / M9-12 補 |
|
||||
| S5 | `firmware-error-view.tsx` | 109-117 | 技術資訊 template literal 較複雜(多層 `${cond ? a : ''}`)、可讀但不易調整。考慮抽 helper `formatTechnicalInfo(progress: FirmwareProgressEvent): string`、減少 JSX 內邏輯 |
|
||||
|
||||
---
|
||||
|
||||
## Design Spec 對齊評估(重點章節)
|
||||
|
||||
| Design Spec 章節 | 對齊狀態 | 評估 |
|
||||
|----------------|---------|------|
|
||||
| §3.4 stage / reason / i18n 對應 | ✅ 完全對齊 | stage 4 種 + reason 8 種 + i18n fallback 完整 |
|
||||
| §6 升級 modal 4 phase(confirming / upgrading / success / error) | ✅ 完全對齊 | 雖然降版(§6 主題)B 階段才做、但 phase machine 結構已預埋 |
|
||||
| §7 8 種 reason → 4 種 UX | ✅ 完全對齊 | `errorMessageKeyFor` + `canRetryReason` + `primaryActionKeyFor` 三個 helper 涵蓋全部 |
|
||||
| §12 R-FW-11 緩解(upgrading 不可關 modal) | ✅ 完全對齊 | 三層擋(onOpenChange / interactOutside / EscapeKeyDown)+ `showCloseButton={false}` |
|
||||
| §9 i18n 完整性 | ⚠️ 部分(MJ1) | namespace 偏離、key 集合本身 A 階段所需都齊 |
|
||||
| §11 Design Tokens(暫代評估) | ✅ 合理 | Tailwind palette 暫代、M9-12 由 Frontend 用 axe-core 落地 6 個 component token、Design §11.2 備註已明示此責任分工 |
|
||||
|
||||
---
|
||||
|
||||
## TDD §4.2 WS schema 對齊
|
||||
|
||||
實質判斷:完全對齊。
|
||||
|
||||
| TDD §4.2 欄位 | Frontend 型別 | Backend struct json | 對齊狀態 |
|
||||
|--------------|--------------|-------------------|---------|
|
||||
| `percent` | `number` | `int` | ✅ |
|
||||
| `stage` | `FirmwareStage` enum | `string` (`preparing/loading/flashing/verifying/done/error`) | ✅ |
|
||||
| `direction` | `'upgrade'\|'downgrade'?` | `string,omitempty` | ✅ |
|
||||
| `message` | `string?` | `string,omitempty` | ✅ |
|
||||
| `elapsedMs` | `number` | `int64` | ✅ |
|
||||
| `etaMs` | `number?` | `int64,omitempty` | ✅ |
|
||||
| `error` | `string?` | `string,omitempty` | ✅ |
|
||||
| `reason` | `FirmwareReason` enum | `string,omitempty` | ✅ |
|
||||
| `rawError` | `string?` | `string,omitempty` | ✅ |
|
||||
| `beforeVersion` | `string?` | `string,omitempty` | ✅ |
|
||||
| `errorCode` | `string?` | `string,omitempty` | ✅ |
|
||||
| `afterVersion` | `string?` | `string,omitempty` | ✅ |
|
||||
| `method` | `string?` | `string,omitempty` | ✅ |
|
||||
| `deviceId` | `string`(在 type 上)| `string` | ✅(backend 在 struct 必填、frontend `FirmwareProgressEvent.deviceId` 也必填)|
|
||||
| `type` (wrapper) | `'firmware_progress'` literal | wrapper struct `Type: "firmware_progress"` | ✅ 對齊(除非看 firmware_ws_test.go:見 MJ3)|
|
||||
|
||||
---
|
||||
|
||||
## 32 新測試 + 2 backend smoke test 評估
|
||||
|
||||
### Frontend tests(32 個)
|
||||
|
||||
| Test 群組 | 數量 | 品質評估 |
|
||||
|---------|------|---------|
|
||||
| `useFirmwareStore — phase transitions` | 8 | 高 — phase machine 完整覆蓋、含 R-FW-11 緩解 + 多裝置隔離 |
|
||||
| `errorMessageKeyFor — reason → i18n key` | 2 | 高 — 8 種 reason + stage fallback 全覆蓋 |
|
||||
| `canRetryReason — destructive failures` | 3 | 高 — 3 destructive + 5 recoverable + undefined 全覆蓋 |
|
||||
| `primaryActionKeyFor — recommended action` | 3 | 高 |
|
||||
| `estimatedDurationSeconds — chip estimates` | 3 | 中 — 涵蓋 KL520/KL720/unknown,但 fallback 語意不一致(見 M3)|
|
||||
| `stageOrdinal — 4-stage vs 3-stage` | 2 | 高 |
|
||||
| `computeBadgeState — AC-FW-1.1 4-color` | 6 | 高 — 含 legacy precedence 邊界 case |
|
||||
| `FirmwareBadge — render + a11y` | 4 | 中 — render & data-state 涵蓋,缺 hover tooltip 互動測試 |
|
||||
|
||||
**漏測項目**:
|
||||
- `useFirmwareProgress` hook(WS connectAndWait timeout / cleanup)— 既有 `useFlashProgress` 也沒測、屬既有 pattern、可接受
|
||||
- `FirmwareErrorView` 元件渲染測試(destructive UI 切換)— 見 M5
|
||||
- `FirmwareUpgradeDialog` integration(phase 切換 + R-FW-11 擋 ESC)— 見 S4
|
||||
|
||||
### Backend smoke tests(2 個)
|
||||
|
||||
| Test | 品質評估 |
|
||||
|------|---------|
|
||||
| `TestFirmwareProgressHandler_ReceivesBroadcast` | 中 — plumbing 正確、但 schema 用錯欄位(`phase` vs `stage`、見 MJ3)|
|
||||
| `TestFirmwareProgressHandler_RoomIsolation` | 高 — room key 隔離驗證合理、connB 不收 + timeout assertion 正確 |
|
||||
|
||||
---
|
||||
|
||||
## 安全軸特別評估(粗篩、不升級)
|
||||
|
||||
| 項目 | 狀態 |
|
||||
|------|------|
|
||||
| WS origin check(loopback only)| ✅ backend 共用 `CheckOrigin`(origin.go:18-40),白名單 127.0.0.1 / localhost / ::1、http only |
|
||||
| raw error 不渲染主畫面 | ✅ 只在 collapsible `<details>` 內(firmware-error-view.tsx:101-118)、預設收合 |
|
||||
| XSS / dangerouslySetInnerHTML / v-html | ✅ grep 零 hit、React 自動 escape 所有 template literal interpolation |
|
||||
| Hardcoded secrets / tokens | ✅ 零 hit |
|
||||
| Auth / session 變更 | N/A — 本批無 auth 改動 |
|
||||
| 新增第三方 SDK | N/A — 無新依賴 |
|
||||
| PII / 金融 / 健康資料處理 | N/A |
|
||||
| 對外 webhook / 新 OAuth | N/A |
|
||||
|
||||
**結論**:5 軸 security 粗篩無發現、**不升級給 Security Auditor**。
|
||||
|
||||
---
|
||||
|
||||
## Concurrency 評估
|
||||
|
||||
### Frontend
|
||||
- ✅ 多裝置隔離:`firmware-store.ts:110-115` 用 `activeDeviceId` mismatch return(對齊 flash-store M4 fix)
|
||||
- ✅ WS race:`connectAndWait` 必須先 resolve 才送 POST(避免 server 在 client 連 WS 前就送 first event)
|
||||
- ✅ Retry 流程:`firmware-upgrade-dialog.tsx:132-136` 重置 progress → 重連 WS → 重送 POST、無 race
|
||||
- ⚠️ `firmware-store.ts:82-108` `startUpgrade` 是 async、其他 phase transition 可能在它執行期間發生(如 cancelConfirm);但 cancelConfirm 已守 `if (phase !== 'confirming') return`、整體安全
|
||||
|
||||
### Backend hot-fix
|
||||
- ✅ 純對稱 flash_ws.go、低風險
|
||||
- ✅ `hub.RegisterSync` 確保 join room 完成才進讀寫 loop(避免 race)
|
||||
- ✅ Read pump goroutine drain incoming(純為偵測斷線)、與主 goroutine 不衝突
|
||||
|
||||
---
|
||||
|
||||
## Concurrency / 跨檔案一致性檢查(R-B1)
|
||||
|
||||
| 比對項目 | 一致性 | 備註 |
|
||||
|---------|-------|------|
|
||||
| Frontend `FirmwareProgressEvent` ↔ Backend `firmwareProgressMessage` + `firmware.FirmwareProgress` | ✅ 13 欄位全對齊 | 含 `type: "firmware_progress"` wrapper |
|
||||
| Frontend `/ws/devices/:id/firmware-progress` ↔ Backend router.go:125 ↔ firmware_ws.go:30 room `firmware:<deviceID>` | ✅ 三端一致 | |
|
||||
| Frontend `firmwareCanUpgrade / firmwareIsLegacy / bundledFirmwareVersion` ↔ Backend DeviceInfo 衍生欄位 | ✅ 完全 | 與 TDD §3.1 一致 |
|
||||
| Frontend `errorMessageKeyFor(stage, reason)` ↔ Design §7.1 8 種 reason | ✅ 8/8 對齊 | 含 stage-only fallback |
|
||||
| Frontend phase machine ↔ Design §8 狀態機 | ✅ 完全 | idle/confirming/upgrading/success/error |
|
||||
| i18n key namespace ↔ Design Spec §9 | ⚠️ 偏離 | 見 MJ1 |
|
||||
| Backend smoke test schema ↔ production `firmwareProgressMessage` | ⚠️ 偏離 | 見 MJ3 |
|
||||
|
||||
---
|
||||
|
||||
## B 層 verification(R-B2 / R-B3 / R-B4)
|
||||
|
||||
| # | 條件 | 狀態 |
|
||||
|---|------|------|
|
||||
| R-B1 | 跨檔案一致性檢查 | ✅ 已做(見上表 7 項比對)|
|
||||
| R-B2 | 漏審檢查 | ✅ 18/18 檔全部覆蓋、無漏審 |
|
||||
| R-B3 | 大改動 commit 拆分 | ✅ 無單檔 > 500 行(最大是 firmware-store.ts 262 行 + 測試 265 行、合理)|
|
||||
| R-B4 | 三方文件互審狀態 | N/A — 本次為程式碼審查、非文件審 |
|
||||
|
||||
**B 層跑了、無暫緩。**
|
||||
|
||||
---
|
||||
|
||||
## 優點(R-A4)
|
||||
|
||||
1. **Pure helper 分離**:`firmware-store.ts:147-243` 5 個 pure functions(無 React 依賴)從 store 拆出、便於測試 + M9-12 B 階段重用。設計遠見好。
|
||||
2. **多裝置隔離正確**:`firmware-store.ts:110-115` 對齊既有 flash-store M4 fix、不重蹈覆轍、跨 dongle 同時升級不互污。
|
||||
3. **R-FW-11 緩解三層擋**:`firmware-upgrade-dialog.tsx:146-160` 用 `onOpenChange` + `onInteractOutside` + `onEscapeKeyDown` + `showCloseButton={false}` 四層、覆蓋所有 modal 關閉路徑。
|
||||
4. **destructive reason 不提供 Retry**:`firmware-error-view.tsx:91-98, 142-146` 對 3 種 destructive reason(disconnect / verify_mismatch / verify_not_found)顯示 brick warning + 不可重試、安全防護到位。
|
||||
5. **Backend hot-fix 對稱**:`firmware_ws.go` 與 `flash_ws.go` 結構 1:1 對稱、減少維護心智成本。
|
||||
6. **A11y 用心**:`role="status" aria-live="polite"`(進度區、不打斷 SR)+ `role="alertdialog"`(error modal、主動朗讀)+ `role="note"` 區別語意級別、合理。
|
||||
7. **註解追溯性強**:每個元件 / 函數 JSDoc 都引用 Design / TDD 章節條目(如「對齊 Design §7.1」「TDD §3.4」),方便未來追溯設計意圖。
|
||||
8. **type 嚴格**:TypeScript 型別嚴謹(`FirmwareStage` / `FirmwareReason` 都是 literal union enum)、無 `any`。
|
||||
9. **預埋 B 階段擴展點**:`firmware-store.ts:1-263` 註解明示「Settings 韌體面板(M9-12 B 階段)日後將與本目錄共用元件」、namespace 設計 forward-compatible。
|
||||
|
||||
---
|
||||
|
||||
## Needs investigation(R-A5)
|
||||
|
||||
| 項目 | 為什麼不確定 | 建議後續行動 |
|
||||
|------|------------|------------|
|
||||
| 1 | Design Spec §9 namespace `settings.firmware.*` vs Frontend 實作 `firmware.*` 哪個是 source of truth | Orchestrator 派 Design Agent 與 Frontend 共同確認、見 MJ1 |
|
||||
| 2 | `showSuccess()` toast 預設停留時間是否符合 Design §5.4 「6 秒」 | Frontend agent 確認 `lib/toast.showSuccess` 預設行為、見 M4 |
|
||||
| 3 | backend `progress.percent` 是否真按 TDD §5.3 表發(5 / 20 / 50 / 90 / 100) | 由 Testing Agent 在 M9-5 三平台實機驗證階段對照、見 S2 |
|
||||
| 4 | `device-card.tsx` modified lines 確切 diff 數(任務說明寫 +X)| 不影響審查結論、log 已涵蓋 |
|
||||
|
||||
---
|
||||
|
||||
## 是否阻擋 M9-5(三平台實機驗證)
|
||||
|
||||
**不阻擋**。3 個 Major 都是:
|
||||
- MJ1:i18n namespace 對齊(純命名、邏輯正確)
|
||||
- MJ2:硬編碼中文 tooltip(一行修)
|
||||
- MJ3:backend smoke test schema 用錯欄位(測試正確性、不影響 production)
|
||||
|
||||
→ Frontend / Backend 第 2 輪可在 M9-5 並行執行期間修完、不阻擋 M9-5 三平台 KL520+KL720 完整 E2E。
|
||||
|
||||
## 是否升 Security Auditor
|
||||
|
||||
**不升**。5 軸 security 粗篩無發現、無 auth/secrets/對外 API/第三方整合變更、WS origin 由 backend 既有 CheckOrigin 共用、raw error 不在主畫面渲染、無 XSS / hardcoded secret。
|
||||
|
||||
## 是否需 Frontend / Backend 第 2 輪
|
||||
|
||||
| Agent | 是否需第 2 輪 | 修哪幾項 |
|
||||
|-------|------------|---------|
|
||||
| Frontend | 是 | MJ1(i18n namespace、需與 Design 對齊)+ MJ2(hardcoded title)+ Minor M1 / M2 / M3 / M4 / M6 / M7 / M8(視 Frontend agent 評估)|
|
||||
| Backend | 否(建議在 M9-5 順手修)| MJ3(smoke test 用 `stage` 而非 `phase`、用 `firmware_progress` 而非 `firmware:progress`)— 屬 testing 範疇、可由 Testing agent 在 M9-5 階段補 |
|
||||
|
||||
---
|
||||
|
||||
## 結論
|
||||
|
||||
**審查結果:⚠️ 需修改後通過(第 1 輪)**
|
||||
|
||||
實作品質高、文件對齊度高、5 軸 + 測試軸都實質審過。3 個 Major 都是 i18n / test schema 層級的對齊問題、不影響核心邏輯與 production 行為。建議 Frontend 第 2 輪聚焦 MJ1 + MJ2 + 部分 Minor、Backend 在 M9-5 順手修 MJ3。M9-5 三平台 E2E 可平行開跑、不被本輪 review 阻擋。
|
||||
|
||||
**主要產出位置**:`.autoflow/05-implementation/review/m9-4-frontend-firmware-review.md`
|
||||
|
||||
---
|
||||
|
||||
## Verification 自評(reviewer §12)
|
||||
|
||||
### A 層
|
||||
- [x] R-A1:5 軸 + 測試軸全跑過(每軸 ≥ 20 字實質判斷、無「OK」一字結案)
|
||||
- [x] R-A2:文件符合性 checklist 完整(PRD 14/14 / TDD 9/9 / Design 13/16 三表全填)
|
||||
- [x] R-A3:3 個 Major 都附 `file:line` + 規則名稱 + 具體建議修改
|
||||
- [x] R-A4:優點段落 9 條(≥ 1 條)
|
||||
- [x] R-A5:Needs investigation 4 項明寫
|
||||
- [x] R-A6:通用 6 條退出條件表全標狀態(5 條未觸發 / 1 條觸發 MJ1 / 1 條 N/A)
|
||||
|
||||
### B 層
|
||||
- [x] R-B1:跨檔案一致性表 7 項
|
||||
- [x] R-B2:18/18 檔覆蓋、無漏審
|
||||
- [x] R-B3:無 > 500 行單檔
|
||||
- [x] R-B4:N/A(純程式碼審查、非文件)+ 原因明寫
|
||||
|
||||
### 總結
|
||||
- B 層**已跑**、無暫緩、無需 Orchestrator 計數機制介入
|
||||
- C 層:本批屬 milestone 內 review、非 PR 合 master 前最終 review、C 層不適用
|
||||
@ -292,7 +292,47 @@
|
||||
- 3 個極小 Suggestion 全部 backend 不需處理(純評估)
|
||||
- [x] **M9-3 整體完成**(2026-05-25)→ 通過、可進 M9-4
|
||||
- [ ] M9-4.5 server SIGTERM + Wails OnBeforeClose(新增、併 M9-4 或之後做)
|
||||
- [ ] M9-4 Frontend FW badge + 升級 modal
|
||||
- [x] **M9-4 Frontend FW badge + 升級 modal 完成**(2026-05-25)
|
||||
- 12 新增 + 4 修改 = 16 檔、3052 行
|
||||
- **新增 i18n**:firmware.* 52 keys + devices.card.fwBadge.* 5 keys = 57 leaf × 2 lang = 114 翻譯字串
|
||||
- **新元件**:FirmwareBadge / FirmwareUpgradeButton / FirmwareUpgradeDialog(4 phase)/ FirmwareProgressView / FirmwareErrorView(8 種 reason)
|
||||
- **Zustand store** + WS hook(pattern 對齊 useFlashProgress)+ 整合 DeviceCard
|
||||
- **R-FW-11 緩解**:upgrading phase modal 不可關(onInteractOutside / onEscapeKeyDown preventDefault + 隱藏 X)
|
||||
- **多裝置隔離(defense in depth)**:firmware-store activeDeviceId mismatch 直接 return
|
||||
- **測試**:51 tests pass(32 新 + 19 既有)、pnpm build 4.6s 全綠、tsc --noEmit 0 error、lint 對我的檔案 0 hit
|
||||
- **發現問題**:backend 缺 `/ws/devices/:id/firmware-progress` endpoint(M9-3 只實作 broadcast、沒實作 WS handler)→ 需 hot-fix
|
||||
- **未做**(範圍外):Settings 韌體面板 / 降版 UI / 版本切換 dropdown → M9-12
|
||||
- [x] **M9-4-hotfix Backend WS endpoint 完成**(2026-05-25)
|
||||
- `server/internal/api/ws/firmware_ws.go`:新檔 50 行(對稱 flash_ws.go、純改名)
|
||||
- `server/internal/api/ws/firmware_ws_test.go`:新檔 165 行、2 smoke tests(broadcast / room isolation)
|
||||
- `server/internal/api/router.go`:+2(route 註冊)
|
||||
- go test -race 全綠(1.964s)/ go vet / build 0 output
|
||||
- [x] **M9-4 Reviewer 第 1 輪完成**(2026-05-25)→ `.autoflow/05-implementation/review/m9-4-frontend-firmware-review.md`
|
||||
- 結論:**0 Critical / 3 Major / 8 Minor / 5 Suggestion、不阻擋 M9-5、不升 security**
|
||||
- **MJ1**:i18n namespace 與 Design §9 偏離(structure mismatch)→ Frontend 第 2 輪
|
||||
- **MJ2**:FirmwareErrorView `title` 硬編碼中文 → 一行修 i18n
|
||||
- **MJ3**:backend smoke test schema 用 `phase` / `firmware:progress`、應為 `stage` / `firmware_progress` → 併 M9-5 順手修
|
||||
- 8 Minor(toast 停留 / Dark mode token TODO / test fallback / 漏 RTL test / disabled ContactSupport UX 等)
|
||||
- 5 Suggestion(modal 寬度 480px / selector 優化 / 抽 helper 等)
|
||||
- **需 Frontend 第 2 輪**(MJ1+MJ2+部分 Minor)、**不需 Backend 第 2 輪**(MJ3 併 M9-5)
|
||||
- [x] **M9-4 Frontend 第 2 輪修改完成**(2026-05-25)
|
||||
- **MJ1 採方案 A**(namespace → `settings.firmware.*`、對齊 Design §9 source of truth)— 而非 Reviewer 偏好的方案 B(flat)
|
||||
- 理由:Design §9 是三方對齊後 SoT、Frontend 不該自行翻案;B 階段 M9-12 會加更多 settings.firmware 下 keys、現在不對齊將造成分散維護
|
||||
- 若 Reviewer 堅持 B、需 Orchestrator 派 Design 先改 spec
|
||||
- **MJ2** 一行修 + 順手把 ContactSupport disabled→enabled + mailto handler
|
||||
- 8 Minor 全處理(含 M5 補 FirmwareErrorView 9 個 RTL tests)
|
||||
- 2 Suggestion 修(S1 480px / S5 抽 formatTechnicalInfo helper)
|
||||
- 3 Suggestion 留 follow-up(S2/S3/S4 屬 Testing 範圍或 acceptable trade-off)
|
||||
- 行數變化:13 檔案、+243/-1
|
||||
- **測試**:60 tests pass(+9 FirmwareErrorView)/ tsc 0 error / pnpm build 成功 / lint firmware/ 0 hit(17 既有 lint 問題不屬 M9-4 範圍、follow-up)
|
||||
- 0 regression
|
||||
- [x] **M9-4 Reviewer 第 2 輪通過**(2026-05-25)→ `.autoflow/05-implementation/review/m9-4-frontend-firmware-review-round2.md`
|
||||
- 結論:**0 Critical / 0 Major / 0 Minor / 2 Suggestion、接受方案 A、不阻擋 M9-5、不需 frontend 第 3 輪、不升 security**
|
||||
- **MJ1 方案 A 接受**:Frontend pushback 邏輯紮實(Design SoT 優先 + B 階段必然要落 settings.firmware + cost 低)、Reviewer 同意
|
||||
- 12 項應修全到位、3 留 follow-up(合理)、1 不適用(MJ3 屬 backend/testing)
|
||||
- 2 nice-to-have Suggestion(mailto 地址抽常數 / handleCopy 補 test)— 不阻擋
|
||||
- ContactSupport mailto 安全性驗證通過(RFC 6068 + encodeURIComponent)
|
||||
- [x] **M9-4 整體完成**(2026-05-25)→ 通過、可進 M9-5
|
||||
- [ ] M9-5 三平台實機驗證
|
||||
- [ ] M9-6 ~ M9-13(B 階段擴展)
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { DeviceStatusBadge } from './device-status';
|
||||
import { FirmwareBadge, FirmwareUpgradeButton } from '@/components/firmware';
|
||||
import { useDeviceStore } from '@/stores/device-store';
|
||||
import { useDevicePreferencesStore } from '@/stores/device-preferences-store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
@ -46,7 +47,9 @@ export function DeviceCard({ device, isFirstCard }: DeviceCardProps) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t('devices.firmware')}</p>
|
||||
<p className="font-medium">{device.firmwareVersion || t('common.na')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<FirmwareBadge device={device} />
|
||||
</div>
|
||||
</div>
|
||||
{device.flashedModel && (
|
||||
<div className="col-span-2">
|
||||
@ -55,6 +58,11 @@ export function DeviceCard({ device, isFirstCard }: DeviceCardProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* M9-4:升級按鈕、由 FirmwareUpgradeButton 內部用 device.firmwareCanUpgrade 決定
|
||||
是否 render(單一責任)。disabled 條件由父元件給(!isBusy),避免與
|
||||
connect / disconnect race。 */}
|
||||
<FirmwareUpgradeButton device={device} disabled={isBusy} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Device, FirmwareBadgeState } from '@/types/device';
|
||||
|
||||
interface FirmwareBadgeProps {
|
||||
device: Device;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 Device 衍生欄位決定 FW badge 狀態(綠 / 黃 / 紅 / 灰)。
|
||||
* 規則依 PRD AC-FW-1.1 + Design §4.1:
|
||||
* - 紅 (legacy):firmwareIsLegacy === true
|
||||
* - 灰 (unknown):缺資訊(沒 firmwareVersion / 沒 bundled)
|
||||
* - 綠 (current):firmwareVersion 與 bundledFirmwareVersion 一致
|
||||
* - 黃 (older):其他(介於兩者之間、A 階段罕見、預留 B 階段)
|
||||
*/
|
||||
export function computeBadgeState(device: Device): FirmwareBadgeState {
|
||||
if (device.firmwareIsLegacy === true) return 'legacy';
|
||||
const fw = device.firmwareVersion?.trim();
|
||||
const bundled = device.bundledFirmwareVersion?.trim();
|
||||
if (!fw || !bundled || bundled === 'unknown') return 'unknown';
|
||||
if (fw === bundled) return 'current';
|
||||
// KDP2 prefix 但版號不同 → older(A 階段罕見、預留)
|
||||
if (fw.startsWith('KDP2') && bundled.startsWith('KDP2') && fw !== bundled) return 'older';
|
||||
// 已知非 legacy、版號落 bundled 之外 → 視為 current(保守、避免無意義誤導)
|
||||
return 'current';
|
||||
}
|
||||
|
||||
// FW badge 配色:M9-4 A 階段先用 Tailwind palette 直貼、避免引入新 semantic token
|
||||
// 還沒過 Design Token 治理流程。完整 6 個 component-level tokens
|
||||
// (`color.fw-badge.{current,older,legacy}.{bg,fg}`)由 M9-12 Frontend 落地、
|
||||
// 對比實測責任在 M9-12(Design Spec v2.2 §11.2 備註明示)。
|
||||
// 本批顏色選擇沿用 Tailwind emerald/amber + 既有 destructive/muted semantic、
|
||||
// 在 Design Spec §11.2 推算對比比率範圍內(current 4.7:1 / older 5.2:1 / legacy 4.8:1)。
|
||||
//
|
||||
// TODO(M9-12): 用 axe-core / Lighthouse 實測對比、若實測 < 4.5:1(WCAG AA)→
|
||||
// 改為 component-level token(color.fw-badge.{state}.{bg,fg})並調整 oklch 值。
|
||||
// 特別注意 Dark mode amber-400 配 text-black 與 emerald-500 配 white 的對比。
|
||||
const badgeStyles: Record<FirmwareBadgeState, string> = {
|
||||
current: 'bg-emerald-600 text-white dark:bg-emerald-500',
|
||||
older: 'bg-amber-500 text-black dark:bg-amber-400 dark:text-black',
|
||||
legacy: 'bg-destructive text-white',
|
||||
unknown: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
/**
|
||||
* FW pill badge — Devices 卡片右上 / 列表顯示。
|
||||
* 純展示元件、不處理點擊(升級行為由 UpgradeButton 處理、Settings 韌體面板 deep-link
|
||||
* 由 M9-12 補)。
|
||||
*/
|
||||
export function FirmwareBadge({ device, className }: FirmwareBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const state = computeBadgeState(device);
|
||||
|
||||
const labelKey = `settings.firmware.badge.${state}` as const;
|
||||
const versionLabel = device.firmwareVersion || t('common.na');
|
||||
|
||||
const tooltipKey = (() => {
|
||||
switch (state) {
|
||||
case 'current':
|
||||
return 'devices.card.fwBadge.tooltipCurrent';
|
||||
case 'older':
|
||||
return 'devices.card.fwBadge.tooltipOlder';
|
||||
case 'legacy':
|
||||
return 'devices.card.fwBadge.tooltipLegacy';
|
||||
default:
|
||||
return 'devices.card.fwBadge.tooltipUnknown';
|
||||
}
|
||||
})();
|
||||
|
||||
const tooltip = t(tooltipKey, { version: versionLabel });
|
||||
|
||||
// 顯示文字:current/older 用版本字串、legacy 用「KDP1」、unknown 用「未知」
|
||||
const displayText = (() => {
|
||||
if (state === 'legacy') return t('settings.firmware.badge.legacy');
|
||||
if (state === 'unknown') return t('settings.firmware.badge.unknown');
|
||||
return versionLabel;
|
||||
})();
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="firmware-badge"
|
||||
data-state={state}
|
||||
title={tooltip}
|
||||
aria-label={t(labelKey) + ' — ' + versionLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap',
|
||||
badgeStyles[state],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="inline-block size-1.5 rounded-full bg-current opacity-70" />
|
||||
<span>{displayText}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Copy } from 'lucide-react';
|
||||
import { useTranslation, type TranslationKey } from '@/lib/i18n';
|
||||
import {
|
||||
canRetryReason,
|
||||
errorMessageKeyFor,
|
||||
primaryActionKeyFor,
|
||||
} from '@/stores/firmware-store';
|
||||
import type { FirmwareProgressEvent } from '@/types/device';
|
||||
|
||||
interface FirmwareErrorViewProps {
|
||||
/** 失敗事件(stage='error' 或 startUpgrade 啟動失敗的合成 event)。 */
|
||||
progress: FirmwareProgressEvent;
|
||||
/** Retry callback;canRetryReason 判定不可重試時、父元件可傳 undefined。 */
|
||||
onRetry?: () => void;
|
||||
/** Close callback — 必須提供(destructive 失敗也要能離開 modal)。 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 FirmwareProgressEvent 的可選欄位攤平為文字行;空欄位略過。
|
||||
* 抽出來方便(a)handleCopy 複製 與(b)<details> 區顯示 共用同一邏輯。
|
||||
* (Reviewer S5:減少 JSX 內 template literal 巢狀)
|
||||
*/
|
||||
function formatTechnicalInfo(progress: FirmwareProgressEvent): string {
|
||||
const lines: string[] = [
|
||||
`stage: ${progress.stage}`,
|
||||
`reason: ${progress.reason ?? 'n/a'}`,
|
||||
`deviceId: ${progress.deviceId}`,
|
||||
];
|
||||
if (progress.beforeVersion) lines.push(`beforeVersion: ${progress.beforeVersion}`);
|
||||
if (progress.errorCode) lines.push(`errorCode: ${progress.errorCode}`);
|
||||
if (progress.elapsedMs) lines.push(`elapsedMs: ${progress.elapsedMs}`);
|
||||
if (progress.rawError) lines.push(`rawError: ${progress.rawError}`);
|
||||
if (progress.error) lines.push(`error: ${progress.error}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 升級失敗 modal 內容。對應 Design §7.2:
|
||||
* - 標題 + icon
|
||||
* - friendly message(依 stage + reason 對應 i18n key)
|
||||
* - errorCode(小字、給技術支援用)
|
||||
* - 技術資訊 collapsible(含 raw error、可複製)
|
||||
* - 動作按鈕:Retry / ReplugRetry / ContactSupport(依 reason 決定)+ Close
|
||||
*
|
||||
* 三種 destructive reason(disconnect / verify_mismatch / verify_not_found)
|
||||
* 不提供 Retry 按鈕、改顯示 brick warning + ContactSupport(可點開 mailto: 求助)。
|
||||
*/
|
||||
export function FirmwareErrorView({ progress, onRetry, onClose }: FirmwareErrorViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [techOpen, setTechOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const messageKey = errorMessageKeyFor(progress.stage, progress.reason);
|
||||
const allowRetry = canRetryReason(progress.reason);
|
||||
const isDestructive =
|
||||
progress.reason === 'disconnect_during_op' ||
|
||||
progress.reason === 'verify_mismatch' ||
|
||||
progress.reason === 'verify_not_found';
|
||||
|
||||
const primaryKey = primaryActionKeyFor(progress.reason);
|
||||
const technicalInfo = formatTechnicalInfo(progress);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard?.writeText(technicalInfo).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
},
|
||||
() => {
|
||||
// clipboard 不可用時靜默失敗(不打斷使用者流程)
|
||||
setCopied(false);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleContactSupport = () => {
|
||||
// mailto: 帶上錯誤代碼(給技術支援快速辨識)
|
||||
const subject = `Firmware operation failed${progress.errorCode ? ` — ${progress.errorCode}` : ''}`;
|
||||
const body = `Technical info:\n\n${technicalInfo}`;
|
||||
const href = `mailto:support@kneron.com?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
// Wails 端 / 瀏覽器端都可正常開 mailto: handler
|
||||
window.open(href, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" role="alertdialog" aria-labelledby="fw-error-title">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div className="space-y-1 min-w-0">
|
||||
<h3 id="fw-error-title" className="text-base font-semibold text-destructive">
|
||||
{t('settings.firmware.error.modalTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground">{t(messageKey as TranslationKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress.errorCode && (
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{t('settings.firmware.error.errorCode', { code: progress.errorCode })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isDestructive && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive"
|
||||
role="note"
|
||||
>
|
||||
{t('settings.firmware.error.brickWarning')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 技術資訊 collapsible */}
|
||||
<details
|
||||
className="rounded-md border bg-muted/30"
|
||||
open={techOpen}
|
||||
onToggle={(e) => setTechOpen((e.currentTarget as HTMLDetailsElement).open)}
|
||||
>
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm font-medium select-none">
|
||||
{t('settings.firmware.error.technicalInfo')}
|
||||
</summary>
|
||||
<pre className="px-3 pb-3 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground select-text">
|
||||
{technicalInfo}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
{copied ? t('settings.firmware.error.copied') : t('settings.firmware.error.copyError')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t('settings.firmware.error.action.close')}
|
||||
</Button>
|
||||
{allowRetry && onRetry && (
|
||||
<Button type="button" onClick={onRetry}>
|
||||
{t(primaryKey as TranslationKey)}
|
||||
</Button>
|
||||
)}
|
||||
{!allowRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleContactSupport}
|
||||
title={t('settings.firmware.error.contactSupportTooltip')}
|
||||
>
|
||||
{t('settings.firmware.error.action.contactSupport')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { stageOrdinal } from '@/stores/firmware-store';
|
||||
import type { FirmwareProgressEvent } from '@/types/device';
|
||||
|
||||
interface FirmwareProgressViewProps {
|
||||
progress: FirmwareProgressEvent | null;
|
||||
isLegacyUpgrade: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress modal 內容(升級進行中)。包含:
|
||||
* - progress bar(讀 ev.percent)
|
||||
* - 階段文字(preparing/loading/flashing/verifying)
|
||||
* - 已耗時 / 預估剩餘
|
||||
* - 紅色 banner「請勿拔除裝置」
|
||||
*
|
||||
* 不處理 modal 開關(由父元件 FirmwareUpgradeDialog 控制)。
|
||||
*
|
||||
* 為符合 R-FW-11 緩解(Design §12.1)、modal 不可關 / ESC 不關 / 點外部不關,
|
||||
* 也由父元件處理。本元件不渲染關閉按鈕。
|
||||
*/
|
||||
export function FirmwareProgressView({ progress, isLegacyUpgrade }: FirmwareProgressViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{t('settings.firmware.progress.stage.preparing', { n: 1, total: isLegacyUpgrade ? 4 : 3 })}</span>
|
||||
</div>
|
||||
<Progress value={0} aria-label="firmware upgrade progress" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { n, total } = stageOrdinal(progress.stage, isLegacyUpgrade);
|
||||
const stageKey = (() => {
|
||||
switch (progress.stage) {
|
||||
case 'preparing':
|
||||
return 'settings.firmware.progress.stage.preparing';
|
||||
case 'loading':
|
||||
return 'settings.firmware.progress.stage.loading';
|
||||
case 'flashing':
|
||||
return 'settings.firmware.progress.stage.flashing';
|
||||
case 'verifying':
|
||||
return 'settings.firmware.progress.stage.verifying';
|
||||
case 'done':
|
||||
return 'settings.firmware.progress.stage.done';
|
||||
default:
|
||||
return 'settings.firmware.progress.stage.preparing';
|
||||
}
|
||||
})();
|
||||
|
||||
const percent = Math.max(0, Math.min(100, progress.percent ?? 0));
|
||||
const elapsedSec = Math.floor((progress.elapsedMs ?? 0) / 1000);
|
||||
const etaSec = progress.etaMs && progress.etaMs > 0 ? Math.ceil(progress.etaMs / 1000) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 py-2">
|
||||
{/* 階段 + 百分比 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span
|
||||
className="font-medium"
|
||||
// role=status + aria-live=polite:讓 screen reader 隨階段更新但不打斷
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{progress.stage === 'done'
|
||||
? t('settings.firmware.progress.stage.done')
|
||||
: t(stageKey, { n, total })}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">{percent}%</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<Progress
|
||||
value={percent}
|
||||
aria-label="firmware upgrade progress"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuetext={`${percent}% — ${t(stageKey, { n, total })}`}
|
||||
/>
|
||||
|
||||
{/* 計時 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground tabular-nums">
|
||||
<span>{t('settings.firmware.progress.elapsed', { seconds: elapsedSec })}</span>
|
||||
{etaSec !== null && progress.stage !== 'done' && (
|
||||
<span>{t('settings.firmware.progress.estimatedRemaining', { seconds: etaSec })}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 紅色警告 banner — R-FW-11 緩解 */}
|
||||
{progress.stage !== 'done' && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{t('settings.firmware.progress.warningUpgrade')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowUpCircle } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { FirmwareUpgradeDialog } from './firmware-upgrade-dialog';
|
||||
import type { Device } from '@/types/device';
|
||||
|
||||
interface FirmwareUpgradeButtonProps {
|
||||
device: Device;
|
||||
/** 按鈕被禁用時的條件(如裝置正在連線 / 推論中、由父元件決定)。 */
|
||||
disabled?: boolean;
|
||||
/** 額外 class。 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「升級韌體」按鈕;只在 device.firmwareCanUpgrade === true 時 render。
|
||||
* 點擊後開 FirmwareUpgradeDialog(confirming → upgrading → success / error)。
|
||||
*/
|
||||
export function FirmwareUpgradeButton({ device, disabled, className }: FirmwareUpgradeButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!device.firmwareCanUpgrade) return null;
|
||||
|
||||
const targetVer = device.bundledFirmwareVersion;
|
||||
const label = targetVer && targetVer !== 'unknown'
|
||||
? t('settings.firmware.button.upgradeTo', { version: targetVer })
|
||||
: t('settings.firmware.button.upgrade');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
className={className}
|
||||
data-testid="firmware-upgrade-btn"
|
||||
>
|
||||
<ArrowUpCircle className="mr-1 h-3.5 w-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
<FirmwareUpgradeDialog device={device} open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { showSuccess } from '@/lib/toast';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useFirmwareStore, estimatedDurationSeconds } from '@/stores/firmware-store';
|
||||
import { useFirmwareProgress } from '@/hooks/use-firmware-progress';
|
||||
import { useDeviceStore } from '@/stores/device-store';
|
||||
import { FirmwareProgressView } from './firmware-progress-view';
|
||||
import { FirmwareErrorView } from './firmware-error-view';
|
||||
import type { Device } from '@/types/device';
|
||||
|
||||
interface FirmwareUpgradeDialogProps {
|
||||
device: Device;
|
||||
open: boolean;
|
||||
/** 父元件控制 open 狀態;本元件只透過 onOpenChange 通知。 */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 升級 modal 主元件、含三個 phase:
|
||||
* 1. confirming → 升級前確認(顯示 from/to + 預估時間 + warning)
|
||||
* 2. upgrading → 進度條 + 階段文字(modal 不可關、R-FW-11 緩解)
|
||||
* 3. success → 自動關閉 + toast(這裡只觸發、不在 modal 內顯示)
|
||||
* 4. error → 失敗訊息 + 重試 / 關閉
|
||||
*
|
||||
* Phase 由 useFirmwareStore 統一管理;本元件負責 UI 呈現與 lifecycle。
|
||||
*
|
||||
* R-FW-11 緩解 implementation:
|
||||
* - upgrading phase:onOpenChange 拒絕 false、ESC / 點外部都不關
|
||||
* - DialogContent showCloseButton={false}(upgrading phase)
|
||||
* - 升級進行中、父元件不能 setOpen(false)(store.phase 還是 upgrading)
|
||||
*/
|
||||
export function FirmwareUpgradeDialog({ device, open, onOpenChange }: FirmwareUpgradeDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
phase,
|
||||
progress,
|
||||
beforeVersion,
|
||||
targetVersion,
|
||||
startedAt,
|
||||
startConfirm,
|
||||
cancelConfirm,
|
||||
startUpgrade,
|
||||
reset,
|
||||
} = useFirmwareStore();
|
||||
const { connectAndWait, disconnect } = useFirmwareProgress(device.id);
|
||||
const fetchDevices = useDeviceStore((s) => s.fetchDevices);
|
||||
|
||||
// 升級 4 階段 vs 3 階段:KDP1 legacy 才有 loader 階段。
|
||||
const isLegacyUpgrade = device.firmwareIsLegacy === true;
|
||||
const targetVer = targetVersion || device.bundledFirmwareVersion || 'latest';
|
||||
const beforeVer = beforeVersion || device.firmwareVersion || t('common.na');
|
||||
const estimatedSec = useMemo(() => estimatedDurationSeconds(device.type), [device.type]);
|
||||
const estimatedLabel = estimatedSec >= 60 ? `${Math.round(estimatedSec / 60)} 分鐘` : `${estimatedSec} 秒`;
|
||||
|
||||
const isInProgress = phase === 'upgrading';
|
||||
|
||||
// open → 自動初始化 confirming phase;close → reset store。
|
||||
useEffect(() => {
|
||||
if (open && phase === 'idle') {
|
||||
startConfirm(
|
||||
device.id,
|
||||
device.firmwareVersion || '',
|
||||
device.bundledFirmwareVersion || '',
|
||||
);
|
||||
} else if (!open) {
|
||||
disconnect();
|
||||
reset();
|
||||
}
|
||||
// 只在 open 變化時跑、其他 deps 不該觸發 effect。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// success phase → 自動關閉 modal + toast + refresh device list。
|
||||
useEffect(() => {
|
||||
if (phase !== 'success' || !progress) return;
|
||||
const elapsedMs =
|
||||
progress.elapsedMs > 0 ? progress.elapsedMs : startedAt ? Date.now() - startedAt : 0;
|
||||
const elapsedSec = Math.max(1, Math.round(elapsedMs / 1000));
|
||||
showSuccess(
|
||||
t('settings.firmware.success.upgradeToast', { deviceName: device.name }) +
|
||||
' — ' +
|
||||
t('settings.firmware.success.upgradeDetail', {
|
||||
from: progress.beforeVersion || beforeVer,
|
||||
to: progress.afterVersion || targetVer,
|
||||
duration: `${elapsedSec}s`,
|
||||
}),
|
||||
// Reviewer M1+M4 修:Design §5.4 要求 toast 停留 6 秒(比預設 4 秒長、讓使用者讀完細節)
|
||||
{ duration: 6000 },
|
||||
);
|
||||
// 重新 fetch device list、確保 firmwareVersion / badge 同步刷新。
|
||||
fetchDevices();
|
||||
// 自動關 modal — modal 1.5s 後關閉、toast 由 showSuccess 自身 6s duration 控制
|
||||
// (AC-FW-1.3 提到 5 秒;這裡 modal 先關、toast 仍在右上角繼續顯示)。
|
||||
const closeTimer = setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
}, 1500);
|
||||
return () => clearTimeout(closeTimer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase]);
|
||||
|
||||
const handleStart = async () => {
|
||||
try {
|
||||
await connectAndWait();
|
||||
} catch (e) {
|
||||
// WS 連不上 → 用 startUpgrade 的失敗 placeholder 模擬 error phase
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useFirmwareStore.setState({
|
||||
phase: 'error',
|
||||
progress: {
|
||||
type: 'firmware_progress',
|
||||
deviceId: device.id,
|
||||
stage: 'error',
|
||||
percent: -1,
|
||||
elapsedMs: 0,
|
||||
error: msg,
|
||||
errorCode: 'WS_CONNECT_TIMEOUT',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await startUpgrade(device.id);
|
||||
};
|
||||
|
||||
const handleRetry = async () => {
|
||||
// 重置進度、保留 device 上下文、重連 WS、再次 POST。
|
||||
useFirmwareStore.setState({ phase: 'confirming', progress: null });
|
||||
await handleStart();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
disconnect();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
// R-FW-11:升級進行中拒絕關閉
|
||||
if (!next && isInProgress) return;
|
||||
if (!next) handleClose();
|
||||
else onOpenChange(true);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
// Reviewer S1:對齊 Design §5.1 規格寬度 480px(shadcn 預設 max-w-lg = 512px)
|
||||
className="sm:max-w-[480px]"
|
||||
showCloseButton={!isInProgress}
|
||||
onInteractOutside={(e) => {
|
||||
if (isInProgress) e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isInProgress) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.firmware.upgradeModal.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.firmware.upgradeModal.heading', { deviceName: device.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Phase: confirming */}
|
||||
{phase === 'confirming' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t('settings.firmware.upgradeModal.from')}</p>
|
||||
<p className="font-medium">{beforeVer}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t('settings.firmware.upgradeModal.to')}</p>
|
||||
<p className="font-medium">{targetVer}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
role="note"
|
||||
>
|
||||
{t('settings.firmware.upgradeModal.warning', { duration: estimatedLabel })}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={cancelConfirm}>
|
||||
{t('settings.firmware.upgradeModal.actionCancel')}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleStart}>
|
||||
{t('settings.firmware.upgradeModal.actionStart')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase: upgrading */}
|
||||
{phase === 'upgrading' && (
|
||||
<FirmwareProgressView progress={progress} isLegacyUpgrade={isLegacyUpgrade} />
|
||||
)}
|
||||
|
||||
{/* Phase: success — 短暫顯示綠勾、由 useEffect 自動關閉 */}
|
||||
{phase === 'success' && progress && (
|
||||
<div className="space-y-3 py-4 text-center">
|
||||
<p className="text-base font-medium text-emerald-600 dark:text-emerald-400">
|
||||
✓ {t('settings.firmware.progress.stage.done')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{progress.afterVersion || targetVer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase: error */}
|
||||
{phase === 'error' && progress && (
|
||||
<FirmwareErrorView
|
||||
progress={progress}
|
||||
onRetry={handleRetry}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
10
local-tool/frontend/src/components/firmware/index.ts
Normal file
10
local-tool/frontend/src/components/firmware/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// firmware/ — M9-4 韌體升級 UI(A 階段、Devices 頁整合)。
|
||||
//
|
||||
// Settings 韌體面板(M9-12 B 階段)日後將與本目錄共用元件、namespace 設計
|
||||
// 已預留(i18n key `firmware.*`、store actions 可被多入口共用)。
|
||||
|
||||
export { FirmwareBadge, computeBadgeState } from './firmware-badge';
|
||||
export { FirmwareUpgradeButton } from './firmware-upgrade-button';
|
||||
export { FirmwareUpgradeDialog } from './firmware-upgrade-dialog';
|
||||
export { FirmwareProgressView } from './firmware-progress-view';
|
||||
export { FirmwareErrorView } from './firmware-error-view';
|
||||
81
local-tool/frontend/src/hooks/use-firmware-progress.ts
Normal file
81
local-tool/frontend/src/hooks/use-firmware-progress.ts
Normal file
@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { createWebSocket } from '@/lib/ws';
|
||||
import { useFirmwareStore } from '@/stores/firmware-store';
|
||||
import type { FirmwareProgressEvent } from '@/types/device';
|
||||
|
||||
/**
|
||||
* 管理 firmware-progress WebSocket(room:`firmware:<deviceId>`)。
|
||||
*
|
||||
* Backend 對應:
|
||||
* - Broadcast:firmware_handler.go `forwardProgressToWS` →
|
||||
* `wsHub.BroadcastToRoom("firmware:"+deviceID, firmwareProgressMessage{...})`
|
||||
* - 客戶端 WS endpoint:`/ws/devices/:id/firmware-progress`
|
||||
* (**Backend 補丁中、與 flash-progress 對稱**)
|
||||
*
|
||||
* Pattern 對齊 `useFlashProgress`:先 imperative `connectAndWait()` 等 WS 開啟、
|
||||
* 再 POST API 觸發任務、避免 WS 還沒 open 就漏掉早期 event。
|
||||
*/
|
||||
export function useFirmwareProgress(deviceId: string) {
|
||||
const handleEvent = useFirmwareStore((s) => s.handleEvent);
|
||||
const wsRef = useRef<ReturnType<typeof createWebSocket> | null>(null);
|
||||
|
||||
// Cleanup on unmount / deviceId change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
const connectAndWait = useCallback(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
wsRef.current?.close();
|
||||
|
||||
let settled = false;
|
||||
const doResolve = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const doReject = (reason: string) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(reason));
|
||||
}
|
||||
};
|
||||
|
||||
const ws = createWebSocket(
|
||||
`/ws/devices/${deviceId}/firmware-progress`,
|
||||
(data) => {
|
||||
// backend `firmwareProgressMessage`:{ type, deviceId, stage, ... }
|
||||
// 我們直接信任 backend schema、強型別 cast。
|
||||
const ev = data as FirmwareProgressEvent;
|
||||
if (!ev || typeof ev !== 'object') return;
|
||||
handleEvent(ev);
|
||||
},
|
||||
() => {
|
||||
doResolve();
|
||||
},
|
||||
);
|
||||
wsRef.current = ws;
|
||||
|
||||
// 3 秒 timeout:對齊既有 useFlashProgress 行為。
|
||||
setTimeout(
|
||||
() => doReject('WebSocket 連線逾時(3 秒),請確認伺服器是否正常運作'),
|
||||
3000,
|
||||
);
|
||||
}),
|
||||
[deviceId, handleEvent],
|
||||
);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { connectAndWait, disconnect };
|
||||
}
|
||||
@ -126,6 +126,15 @@ export const en: TranslationDict = {
|
||||
preparingFlash: 'Preparing flash...',
|
||||
flashComplete: 'Flash complete!',
|
||||
},
|
||||
card: {
|
||||
fwBadge: {
|
||||
tooltipCurrent: 'Firmware is up to date ({version}).',
|
||||
tooltipOlder: 'Firmware is older. Upgrade recommended.',
|
||||
tooltipLegacy: 'Legacy KDP1 firmware. Upgrade to KDP2 for full feature support.',
|
||||
tooltipUnknown: 'Firmware status unknown. Please rescan the device.',
|
||||
deepLinkA11y: 'Firmware status \u2014 {deviceName}',
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
title: 'Model Library',
|
||||
@ -293,6 +302,72 @@ export const en: TranslationDict = {
|
||||
resetAll: 'Reset All Settings',
|
||||
resetAllHint: 'Restore all preferences to their defaults. This does not delete data.',
|
||||
},
|
||||
firmware: {
|
||||
badge: {
|
||||
current: 'Latest',
|
||||
older: 'Upgrade available',
|
||||
legacy: 'KDP1',
|
||||
unknown: 'Unknown',
|
||||
},
|
||||
button: {
|
||||
upgrade: 'Upgrade firmware',
|
||||
upgradeTo: 'Upgrade to {version}',
|
||||
},
|
||||
upgradeModal: {
|
||||
title: 'Upgrade Firmware',
|
||||
heading: 'Upgrade firmware on {deviceName}',
|
||||
from: 'Current version',
|
||||
to: 'Upgrading to',
|
||||
warning: 'Upgrade takes ~{duration}. Do not unplug the device or close the application. The device will be re-detected automatically when done.',
|
||||
actionStart: 'Start upgrade',
|
||||
actionCancel: 'Cancel',
|
||||
},
|
||||
progress: {
|
||||
upgradeTitle: 'Upgrading Firmware',
|
||||
deviceHeading: 'Upgrading firmware on {deviceName}',
|
||||
stage: {
|
||||
preparing: 'Stage {n} / {total}: Preparing (detect + connect)',
|
||||
loading: 'Stage {n} / {total}: Loading bootloader',
|
||||
flashing: 'Stage {n} / {total}: Flashing firmware',
|
||||
verifying: 'Stage {n} / {total}: Verifying',
|
||||
done: 'Upgrade complete',
|
||||
},
|
||||
warningUpgrade: '⚠ Do not unplug the device',
|
||||
estimatedRemaining: '~{seconds}s remaining',
|
||||
elapsed: 'Elapsed {seconds}s',
|
||||
},
|
||||
success: {
|
||||
upgradeToast: '{deviceName} upgrade succeeded',
|
||||
upgradeDetail: 'From {from} to {to} (took {duration})',
|
||||
partialSuccess: 'Firmware may have been written but verification failed. Please unplug, reconnect, and rescan.',
|
||||
},
|
||||
error: {
|
||||
modalTitle: 'Firmware operation failed',
|
||||
message: {
|
||||
scanNotFound: 'Device not found, possibly disconnected. Please unplug and retry.',
|
||||
connectFailed: 'Cannot connect to device. Please check the USB connection and retry.',
|
||||
loaderWriteFailed: 'Bootloader load failed. Please unplug, reconnect, and retry.',
|
||||
upgradeMidFailed: 'Firmware write failed. Possible hardware issue, please contact technical support.',
|
||||
disconnect: 'Device disconnected during operation. State unknown; the device may be damaged. Please contact technical support.',
|
||||
timeout: 'Operation timed out. The operation may or may not have completed. Please unplug, reconnect, and rescan.',
|
||||
verifyMismatch: 'Firmware written but verification failed. The device may be damaged. Please contact technical support.',
|
||||
verifyNotFound: 'Device not found after upgrade. The device may be damaged. Please contact technical support.',
|
||||
},
|
||||
errorCode: 'Error code: {code}',
|
||||
technicalInfo: 'Technical info',
|
||||
copyError: 'Copy error info',
|
||||
copied: 'Copied ✓',
|
||||
action: {
|
||||
close: 'Close',
|
||||
retry: 'Retry',
|
||||
replugRetry: 'Unplug and retry',
|
||||
rescan: 'Unplug and rescan',
|
||||
contactSupport: 'Contact technical support',
|
||||
},
|
||||
contactSupportTooltip: 'Please contact technical support',
|
||||
brickWarning: '⚠ The device may be damaged. Please contact technical support.',
|
||||
},
|
||||
},
|
||||
serverConfig: 'Server Configuration',
|
||||
backendUrl: 'Backend URL',
|
||||
backendUrlPlaceholder: 'e.g., http://192.168.1.100:3721',
|
||||
|
||||
@ -124,6 +124,15 @@ export interface TranslationDict {
|
||||
preparingFlash: string;
|
||||
flashComplete: string;
|
||||
};
|
||||
card: {
|
||||
fwBadge: {
|
||||
tooltipCurrent: string;
|
||||
tooltipOlder: string;
|
||||
tooltipLegacy: string;
|
||||
tooltipUnknown: string;
|
||||
deepLinkA11y: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
models: {
|
||||
title: string;
|
||||
@ -291,6 +300,79 @@ export interface TranslationDict {
|
||||
resetAll: string;
|
||||
resetAllHint: string;
|
||||
};
|
||||
// M9-4 firmware 升級 UI(namespace 對齊 Design Spec v2.2 §9)。
|
||||
// 雖 A 階段尚未有 Settings → 韌體分頁的實際 UI(M9-12 B 階段才落地),
|
||||
// 但 i18n keys 預先對齊 Design §9 `settings.firmware.*` namespace,
|
||||
// 避免 B 階段需大規模 rename。本批只實作 §9.6(progress)/ §9.4(upgradeModal)/
|
||||
// §9.7(success)/ §9.8(error)/ §9 badge label(badge.*);
|
||||
// 9.1 page 結構 / 9.2 device card label / 9.3 switch accordion / 9.5 downgrade modal
|
||||
// 留待 M9-12 B 階段補。
|
||||
firmware: {
|
||||
badge: {
|
||||
current: string;
|
||||
older: string;
|
||||
legacy: string;
|
||||
unknown: string;
|
||||
};
|
||||
button: {
|
||||
upgrade: string;
|
||||
upgradeTo: string;
|
||||
};
|
||||
upgradeModal: {
|
||||
title: string;
|
||||
heading: string;
|
||||
from: string;
|
||||
to: string;
|
||||
warning: string;
|
||||
actionStart: string;
|
||||
actionCancel: string;
|
||||
};
|
||||
progress: {
|
||||
upgradeTitle: string;
|
||||
deviceHeading: string;
|
||||
stage: {
|
||||
preparing: string;
|
||||
loading: string;
|
||||
flashing: string;
|
||||
verifying: string;
|
||||
done: string;
|
||||
};
|
||||
warningUpgrade: string;
|
||||
estimatedRemaining: string;
|
||||
elapsed: string;
|
||||
};
|
||||
success: {
|
||||
upgradeToast: string;
|
||||
upgradeDetail: string;
|
||||
partialSuccess: string;
|
||||
};
|
||||
error: {
|
||||
modalTitle: string;
|
||||
message: {
|
||||
scanNotFound: string;
|
||||
connectFailed: string;
|
||||
loaderWriteFailed: string;
|
||||
upgradeMidFailed: string;
|
||||
disconnect: string;
|
||||
timeout: string;
|
||||
verifyMismatch: string;
|
||||
verifyNotFound: string;
|
||||
};
|
||||
errorCode: string;
|
||||
technicalInfo: string;
|
||||
copyError: string;
|
||||
copied: string;
|
||||
action: {
|
||||
close: string;
|
||||
retry: string;
|
||||
replugRetry: string;
|
||||
rescan: string;
|
||||
contactSupport: string;
|
||||
};
|
||||
contactSupportTooltip: string;
|
||||
brickWarning: string;
|
||||
};
|
||||
};
|
||||
serverConfig: string;
|
||||
backendUrl: string;
|
||||
backendUrlPlaceholder: string;
|
||||
|
||||
@ -126,6 +126,15 @@ export const zhTW: TranslationDict = {
|
||||
preparingFlash: '準備燒錄中...',
|
||||
flashComplete: '燒錄完成!',
|
||||
},
|
||||
card: {
|
||||
fwBadge: {
|
||||
tooltipCurrent: '韌體為最新版本({version})。',
|
||||
tooltipOlder: '韌體版本較舊,建議升級。',
|
||||
tooltipLegacy: '此韌體為舊版 KDP1,建議升級到 KDP2 以支援完整功能。',
|
||||
tooltipUnknown: '韌體狀態未知。請重新掃描裝置。',
|
||||
deepLinkA11y: '韌體狀態 — {deviceName}',
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
title: '模型庫',
|
||||
@ -293,6 +302,72 @@ export const zhTW: TranslationDict = {
|
||||
resetAll: '重置所有設定',
|
||||
resetAllHint: '將所有偏好恢復成預設值,不會刪除資料。',
|
||||
},
|
||||
firmware: {
|
||||
badge: {
|
||||
current: '最新',
|
||||
older: '可升級',
|
||||
legacy: 'KDP1',
|
||||
unknown: '未知',
|
||||
},
|
||||
button: {
|
||||
upgrade: '升級韌體',
|
||||
upgradeTo: '升級到 {version}',
|
||||
},
|
||||
upgradeModal: {
|
||||
title: '升級韌體',
|
||||
heading: '升級 {deviceName} 的韌體',
|
||||
from: '當前版本',
|
||||
to: '升級到',
|
||||
warning: '升級過程約需 {duration},期間切勿拔除裝置或關閉應用程式。完成後裝置會自動重新識別。',
|
||||
actionStart: '開始升級',
|
||||
actionCancel: '取消',
|
||||
},
|
||||
progress: {
|
||||
upgradeTitle: '升級韌體',
|
||||
deviceHeading: '升級 {deviceName} 的韌體',
|
||||
stage: {
|
||||
preparing: '階段 {n} / {total}:準備(偵測 + 連接裝置)',
|
||||
loading: '階段 {n} / {total}:載入引導程式',
|
||||
flashing: '階段 {n} / {total}:寫入韌體',
|
||||
verifying: '階段 {n} / {total}:驗證完成',
|
||||
done: '升級完成',
|
||||
},
|
||||
warningUpgrade: '⚠ 請勿拔除裝置',
|
||||
estimatedRemaining: '預估剩餘 {seconds} 秒',
|
||||
elapsed: '已耗時 {seconds} 秒',
|
||||
},
|
||||
success: {
|
||||
upgradeToast: '{deviceName} 升級成功',
|
||||
upgradeDetail: '從 {from} 升級到 {to}(耗時 {duration})',
|
||||
partialSuccess: '韌體可能已升級但無法驗證,請重新插拔裝置後重新掃描。',
|
||||
},
|
||||
error: {
|
||||
modalTitle: '韌體操作失敗',
|
||||
message: {
|
||||
scanNotFound: '找不到裝置,可能已斷開。請重新插拔 dongle 後重試。',
|
||||
connectFailed: '無法連接裝置,請確認 USB 連接後重試。',
|
||||
loaderWriteFailed: '引導程式載入失敗。請拔除裝置後重新插入並重試。',
|
||||
upgradeMidFailed: '韌體寫入失敗。可能是裝置硬體異常,請聯絡技術支援。',
|
||||
disconnect: '裝置在操作過程中斷開,狀態未知。可能造成裝置損毀,請聯絡技術支援。',
|
||||
timeout: '操作超時。可能成功也可能未完成,請拔除裝置後重新掃描以確認狀態。',
|
||||
verifyMismatch: '韌體已寫入但驗證未通過。可能造成裝置損毀,請聯絡技術支援。',
|
||||
verifyNotFound: '升級後找不到裝置。可能造成裝置損毀,請聯絡技術支援。',
|
||||
},
|
||||
errorCode: '錯誤代碼:{code}',
|
||||
technicalInfo: '技術資訊',
|
||||
copyError: '複製錯誤訊息',
|
||||
copied: '已複製 ✓',
|
||||
action: {
|
||||
close: '關閉',
|
||||
retry: '重試',
|
||||
replugRetry: '重新插拔後重試',
|
||||
rescan: '拔插後重新掃描',
|
||||
contactSupport: '聯絡技術支援',
|
||||
},
|
||||
contactSupportTooltip: '請聯絡技術支援',
|
||||
brickWarning: '⚠ 裝置可能已損毀,請聯絡技術支援。',
|
||||
},
|
||||
},
|
||||
serverConfig: '伺服器設定',
|
||||
backendUrl: '後端 URL',
|
||||
backendUrlPlaceholder: '例如:http://192.168.1.100:3721',
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import { toast } from 'sonner';
|
||||
import { getTranslation } from '@/lib/i18n';
|
||||
|
||||
export function showSuccess(message: string) {
|
||||
toast.success(message);
|
||||
interface ToastOptions {
|
||||
/** Toast 停留時間(ms),未傳則用 sonner 預設(4000ms)。 */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export function showError(message: string) {
|
||||
toast.error(message);
|
||||
export function showSuccess(message: string, options?: ToastOptions) {
|
||||
toast.success(message, options);
|
||||
}
|
||||
|
||||
export function showInfo(message: string) {
|
||||
toast.info(message);
|
||||
export function showError(message: string, options?: ToastOptions) {
|
||||
toast.error(message, options);
|
||||
}
|
||||
|
||||
export function showInfo(message: string, options?: ToastOptions) {
|
||||
toast.info(message, options);
|
||||
}
|
||||
|
||||
export function showApiError(error?: { code: string; message: string }) {
|
||||
|
||||
273
local-tool/frontend/src/stores/firmware-store.ts
Normal file
273
local-tool/frontend/src/stores/firmware-store.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
import { showApiError } from '@/lib/toast';
|
||||
import { useActivityStore } from './activity-store';
|
||||
import type {
|
||||
FirmwareProgressEvent,
|
||||
FirmwareStage,
|
||||
FirmwareReason,
|
||||
FirmwareActiveTask,
|
||||
} from '@/types/device';
|
||||
|
||||
// FirmwareUpgradeState 描述目前螢幕上 firmware modal 的狀態。
|
||||
// per-device 隔離(activeDeviceId 機制、對齊 flash-store M4 fix)—
|
||||
// 避免多 dongle 升級時、Frontend WS 訊息互相蓋掉。
|
||||
//
|
||||
// 設計參考:design-spec v2.2 §8 狀態機。本期 M9-4 A 階段只實作
|
||||
// upgrade direction(downgrade B2 階段 M9-12 才開)。
|
||||
interface FirmwareState {
|
||||
// 當前正在升級的 device。null = 沒有升級在進行中。
|
||||
activeDeviceId: string | null;
|
||||
// last task — 升級啟動時 backend 回的 taskId。診斷用。
|
||||
activeTaskId: string | null;
|
||||
// confirming:升級確認 modal 顯示中、尚未送 API。
|
||||
// upgrading:API 已送出 / WS 正在推進度。
|
||||
// success:完成、toast 已出。
|
||||
// error:失敗、error modal 顯示中。
|
||||
// idle:modal 關閉、無工作中。
|
||||
phase: 'idle' | 'confirming' | 'upgrading' | 'success' | 'error';
|
||||
// 當前最新的 progress event。
|
||||
progress: FirmwareProgressEvent | null;
|
||||
// 升級開始時的版本字串(讓 success toast 顯示 from→to)。
|
||||
beforeVersion: string | null;
|
||||
// 升級開始時的目標版本(bundled)字串。
|
||||
targetVersion: string | null;
|
||||
// 升級開始的 wall clock(ms)— 用來算 toast duration。
|
||||
startedAt: number | null;
|
||||
|
||||
// ─── Actions ───
|
||||
startConfirm: (deviceId: string, beforeVersion: string, targetVersion: string) => void;
|
||||
cancelConfirm: () => void;
|
||||
startUpgrade: (deviceId: string) => Promise<{ ok: boolean; taskId?: string; error?: string }>;
|
||||
handleEvent: (ev: FirmwareProgressEvent) => void;
|
||||
/** 把 store 重設到 idle(modal 關閉、可開新升級)。 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useFirmwareStore = create<FirmwareState>((set, get) => ({
|
||||
activeDeviceId: null,
|
||||
activeTaskId: null,
|
||||
phase: 'idle',
|
||||
progress: null,
|
||||
beforeVersion: null,
|
||||
targetVersion: null,
|
||||
startedAt: null,
|
||||
|
||||
startConfirm: (deviceId, beforeVersion, targetVersion) => {
|
||||
set({
|
||||
activeDeviceId: deviceId,
|
||||
phase: 'confirming',
|
||||
progress: null,
|
||||
beforeVersion,
|
||||
targetVersion,
|
||||
startedAt: null,
|
||||
activeTaskId: null,
|
||||
});
|
||||
},
|
||||
|
||||
cancelConfirm: () => {
|
||||
// 只允許在 confirming 階段取消、進度中不可取消(R-FW-11 緩解)。
|
||||
if (get().phase !== 'confirming') return;
|
||||
set({
|
||||
activeDeviceId: null,
|
||||
phase: 'idle',
|
||||
progress: null,
|
||||
beforeVersion: null,
|
||||
targetVersion: null,
|
||||
startedAt: null,
|
||||
activeTaskId: null,
|
||||
});
|
||||
},
|
||||
|
||||
startUpgrade: async (deviceId) => {
|
||||
set({ phase: 'upgrading', startedAt: Date.now(), progress: null });
|
||||
type StartResp = { taskId: string };
|
||||
const res = await api.post<StartResp>(`/devices/${deviceId}/firmware/upgrade`);
|
||||
if (!res.success || !res.data) {
|
||||
const msg = res.error?.message || 'Firmware upgrade failed to start';
|
||||
showApiError(res.error);
|
||||
// 啟動失敗:直接進 error phase、給 modal 顯示
|
||||
set({
|
||||
phase: 'error',
|
||||
progress: {
|
||||
type: 'firmware_progress',
|
||||
deviceId,
|
||||
stage: 'error',
|
||||
percent: -1,
|
||||
elapsedMs: 0,
|
||||
error: msg,
|
||||
errorCode: res.error?.code,
|
||||
},
|
||||
});
|
||||
useActivityStore.getState().addActivity('flash_error', `Firmware upgrade start failed: ${msg}`);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
set({ activeTaskId: res.data.taskId });
|
||||
useActivityStore.getState().addActivity('flash_start', `Firmware upgrade started: ${deviceId}`);
|
||||
return { ok: true, taskId: res.data.taskId };
|
||||
},
|
||||
|
||||
handleEvent: (ev) => {
|
||||
// 只處理當前 active device 的 event(多裝置同時升級時也只更新對應的)。
|
||||
const active = get().activeDeviceId;
|
||||
if (active !== null && active !== ev.deviceId) {
|
||||
return;
|
||||
}
|
||||
// 記錄事件本身
|
||||
set({ progress: ev });
|
||||
|
||||
if (ev.stage === 'done') {
|
||||
set({ phase: 'success' });
|
||||
useActivityStore.getState().addActivity('flash_complete', `Firmware upgraded: ${ev.deviceId}`);
|
||||
return;
|
||||
}
|
||||
if (ev.stage === 'error') {
|
||||
set({ phase: 'error' });
|
||||
useActivityStore
|
||||
.getState()
|
||||
.addActivity('flash_error', `Firmware upgrade failed: ${ev.error || ev.reason || 'unknown'}`);
|
||||
return;
|
||||
}
|
||||
// preparing / loading / flashing / verifying — phase 維持 upgrading
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
activeDeviceId: null,
|
||||
activeTaskId: null,
|
||||
phase: 'idle',
|
||||
progress: null,
|
||||
beforeVersion: null,
|
||||
targetVersion: null,
|
||||
startedAt: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Pure helpers(無 React 依賴、可直接 import)───
|
||||
|
||||
/** 從 stage + reason 推回顯示的 friendly message i18n key(對齊 Design §7.1 + §9.8 namespace)。 */
|
||||
export function errorMessageKeyFor(stage: FirmwareStage, reason?: FirmwareReason): string {
|
||||
if (reason === 'timeout') return 'settings.firmware.error.message.timeout';
|
||||
if (reason === 'disconnect_during_op') return 'settings.firmware.error.message.disconnect';
|
||||
if (reason === 'scan_not_found') return 'settings.firmware.error.message.scanNotFound';
|
||||
if (reason === 'connect_failed') return 'settings.firmware.error.message.connectFailed';
|
||||
if (reason === 'loader_write_failed') return 'settings.firmware.error.message.loaderWriteFailed';
|
||||
if (reason === 'upgrade_mid_failed') return 'settings.firmware.error.message.upgradeMidFailed';
|
||||
if (reason === 'verify_mismatch') return 'settings.firmware.error.message.verifyMismatch';
|
||||
if (reason === 'verify_not_found') return 'settings.firmware.error.message.verifyNotFound';
|
||||
// fallback by stage
|
||||
switch (stage) {
|
||||
case 'preparing':
|
||||
return 'settings.firmware.error.message.connectFailed';
|
||||
case 'loading':
|
||||
return 'settings.firmware.error.message.loaderWriteFailed';
|
||||
case 'flashing':
|
||||
return 'settings.firmware.error.message.upgradeMidFailed';
|
||||
case 'verifying':
|
||||
return 'settings.firmware.error.message.verifyMismatch';
|
||||
default:
|
||||
return 'settings.firmware.error.message.upgradeMidFailed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* canRetry 判定某個 reason 是否可重試(Design §7、安全考量)。
|
||||
* disconnect / verify_mismatch / verify_not_found 三種屬於 destructive 失敗
|
||||
* (可能 brick)、不提供 Retry 按鈕、只能 ContactSupport。
|
||||
*/
|
||||
export function canRetryReason(reason?: FirmwareReason): boolean {
|
||||
if (!reason) return true;
|
||||
return (
|
||||
reason !== 'disconnect_during_op' &&
|
||||
reason !== 'verify_mismatch' &&
|
||||
reason !== 'verify_not_found'
|
||||
);
|
||||
}
|
||||
|
||||
/** 從 reason 決定主要建議動作的 i18n key(對齊 Design §9.8 namespace)。 */
|
||||
export function primaryActionKeyFor(reason?: FirmwareReason): string {
|
||||
if (reason === 'scan_not_found' || reason === 'disconnect_during_op') {
|
||||
return 'settings.firmware.error.action.replugRetry';
|
||||
}
|
||||
if (reason === 'verify_mismatch' || reason === 'verify_not_found') {
|
||||
return 'settings.firmware.error.action.rescan';
|
||||
}
|
||||
if (reason === 'timeout') {
|
||||
return 'settings.firmware.error.action.rescan';
|
||||
}
|
||||
return 'settings.firmware.error.action.retry';
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 chip type 推估升級時間(AC-FW-1.7)。
|
||||
* KL520 ~ 30s(實測)、KL720 ~ 180s(實測)。
|
||||
*
|
||||
* Fallback 邏輯(M9-4 v2 對齊 Reviewer M3 釐清):
|
||||
* - `undefined` deviceType(caller 沒拿到 chip 資訊)→ 保守值 60s(介於 KL520 與 KL720 之間、避免過度樂觀)
|
||||
* - 有 deviceType 字串但 toLowerCase 後不含 kl720 → 預設走 KL520 路徑(30s)
|
||||
*
|
||||
* 兩種 fallback 數值不同是刻意的:「不知道有沒有 type」和「有 type 但認不出」資訊量不同。
|
||||
*/
|
||||
export function estimatedDurationSeconds(deviceType?: string): number {
|
||||
if (!deviceType) return 60;
|
||||
const low = deviceType.toLowerCase();
|
||||
if (low.includes('kl720')) return 180;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/** 從後端 stage 算「階段 n / total」— KDP1→KDP2 = 4 階段、KDP2→KDP2 = 3 階段。 */
|
||||
export function stageOrdinal(
|
||||
stage: FirmwareStage,
|
||||
isLegacyUpgrade: boolean,
|
||||
): { n: number; total: number } {
|
||||
const total = isLegacyUpgrade ? 4 : 3;
|
||||
if (isLegacyUpgrade) {
|
||||
switch (stage) {
|
||||
case 'preparing':
|
||||
return { n: 1, total };
|
||||
case 'loading':
|
||||
return { n: 2, total };
|
||||
case 'flashing':
|
||||
return { n: 3, total };
|
||||
case 'verifying':
|
||||
return { n: 4, total };
|
||||
default:
|
||||
return { n: total, total };
|
||||
}
|
||||
}
|
||||
switch (stage) {
|
||||
case 'preparing':
|
||||
return { n: 1, total };
|
||||
case 'flashing':
|
||||
return { n: 2, total };
|
||||
case 'verifying':
|
||||
return { n: 3, total };
|
||||
default:
|
||||
return { n: total, total };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Active tasks API(給 M9-4.5 Wails OnBeforeClose 銜接用)───
|
||||
|
||||
/**
|
||||
* 查詢 backend 目前有沒有進行中的 firmware task。
|
||||
* 主要呼叫時機:Wails 控制台 OnBeforeClose(拒絕關閉時帶 task 清單給 UI)。
|
||||
* Frontend 一般 UI 流程不需要 polling 這個 endpoint、用 WS 即時更新就夠。
|
||||
*
|
||||
* 刻意設計為 module-level helper、不放 store action:
|
||||
* - 此函數無 reactive state(不更新 zustand store、純 fetch + return)
|
||||
* - 呼叫端是 Wails callback / 一次性查詢、不需 subscribe
|
||||
* - 放 store 會造成 hook + action 介面雜訊(store 該專注 modal state machine)
|
||||
*/
|
||||
export async function fetchActiveFirmwareTasks(): Promise<{
|
||||
hasActive: boolean;
|
||||
tasks: FirmwareActiveTask[];
|
||||
}> {
|
||||
type Resp = { hasActive: boolean; tasks: FirmwareActiveTask[] };
|
||||
const res = await api.get<Resp>('/firmware/active-tasks');
|
||||
if (res.success && res.data) {
|
||||
return { hasActive: !!res.data.hasActive, tasks: res.data.tasks || [] };
|
||||
}
|
||||
return { hasActive: false, tasks: [] };
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { computeBadgeState, FirmwareBadge } from '@/components/firmware/firmware-badge';
|
||||
import type { Device } from '@/types/device';
|
||||
|
||||
function makeDevice(overrides: Partial<Device> = {}): Device {
|
||||
return {
|
||||
id: 'dev-1',
|
||||
name: 'KL520 #1',
|
||||
type: 'kneron_kl520',
|
||||
port: '/dev/usb0',
|
||||
status: 'connected',
|
||||
firmwareVersion: 'v2.2.0',
|
||||
bundledFirmwareVersion: 'v2.2.0',
|
||||
firmwareIsLegacy: false,
|
||||
firmwareCanUpgrade: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeBadgeState — AC-FW-1.1 4-color rule', () => {
|
||||
it('returns legacy when firmwareIsLegacy is true', () => {
|
||||
expect(
|
||||
computeBadgeState(
|
||||
makeDevice({ firmwareIsLegacy: true, firmwareVersion: 'KDP1' }),
|
||||
),
|
||||
).toBe('legacy');
|
||||
});
|
||||
|
||||
it('returns unknown when firmwareVersion is missing', () => {
|
||||
expect(computeBadgeState(makeDevice({ firmwareVersion: '' }))).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown when bundledFirmwareVersion is missing or "unknown"', () => {
|
||||
expect(
|
||||
computeBadgeState(makeDevice({ bundledFirmwareVersion: '' })),
|
||||
).toBe('unknown');
|
||||
expect(
|
||||
computeBadgeState(makeDevice({ bundledFirmwareVersion: 'unknown' })),
|
||||
).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns current when firmwareVersion equals bundled', () => {
|
||||
expect(computeBadgeState(makeDevice())).toBe('current');
|
||||
});
|
||||
|
||||
it('returns older when both KDP2 but versions differ', () => {
|
||||
expect(
|
||||
computeBadgeState(
|
||||
makeDevice({ firmwareVersion: 'KDP2.1.0', bundledFirmwareVersion: 'KDP2.2.0' }),
|
||||
),
|
||||
).toBe('older');
|
||||
});
|
||||
|
||||
it('legacy precedence: if isLegacy=true, even with matching version returns legacy', () => {
|
||||
// 邊界 case:driver 端 isLegacy=true 但 firmwareVersion 又恰好等於 bundled
|
||||
// (理論不會發生、但若發生、isLegacy 優先)。
|
||||
expect(
|
||||
computeBadgeState(
|
||||
makeDevice({ firmwareIsLegacy: true, firmwareVersion: 'v2.2.0' }),
|
||||
),
|
||||
).toBe('legacy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FirmwareBadge — renders correct state + a11y', () => {
|
||||
it('renders with data-state attribute matching computeBadgeState', () => {
|
||||
const { container } = render(<FirmwareBadge device={makeDevice()} />);
|
||||
const el = container.querySelector('[data-testid="firmware-badge"]');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.getAttribute('data-state')).toBe('current');
|
||||
});
|
||||
|
||||
it('renders legacy badge with KDP1 label', () => {
|
||||
render(
|
||||
<FirmwareBadge
|
||||
device={makeDevice({ firmwareIsLegacy: true, firmwareVersion: 'KDP1' })}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByTestId('firmware-badge');
|
||||
expect(badge.getAttribute('data-state')).toBe('legacy');
|
||||
// 顯示 KDP1 文字(不是版本字串)
|
||||
expect(badge.textContent).toContain('KDP1');
|
||||
});
|
||||
|
||||
it('renders unknown badge when firmwareVersion is missing', () => {
|
||||
render(<FirmwareBadge device={makeDevice({ firmwareVersion: '' })} />);
|
||||
const badge = screen.getByTestId('firmware-badge');
|
||||
expect(badge.getAttribute('data-state')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('has aria-label and title (tooltip)', () => {
|
||||
render(<FirmwareBadge device={makeDevice()} />);
|
||||
const badge = screen.getByTestId('firmware-badge');
|
||||
expect(badge.getAttribute('aria-label')).toBeTruthy();
|
||||
expect(badge.getAttribute('title')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { FirmwareErrorView } from '@/components/firmware/firmware-error-view';
|
||||
import type { FirmwareProgressEvent, FirmwareReason } from '@/types/device';
|
||||
|
||||
/**
|
||||
* M9-4 第 2 輪修改補測(Reviewer M5):
|
||||
* 驗證 destructive vs recoverable reason 在 FirmwareErrorView 的 UI 差異。
|
||||
*
|
||||
* 涵蓋:
|
||||
* - destructive reason 顯示 brick warning
|
||||
* - destructive reason 不顯示 Retry 按鈕、改顯示 ContactSupport 按鈕(enabled、Reviewer M6 已修)
|
||||
* - recoverable reason 顯示 Retry 按鈕(onRetry 有傳)
|
||||
* - errorCode 顯示
|
||||
* - 技術資訊 collapsible 預設收合
|
||||
* - ContactSupport 按鈕點擊會 trigger window.open(mailto:)
|
||||
*/
|
||||
|
||||
function makeEvent(overrides: Partial<FirmwareProgressEvent> = {}): FirmwareProgressEvent {
|
||||
return {
|
||||
type: 'firmware_progress',
|
||||
deviceId: 'dev-1',
|
||||
stage: 'error',
|
||||
percent: -1,
|
||||
elapsedMs: 12000,
|
||||
errorCode: 'FW_E102',
|
||||
rawError: 'kp_update_kdp_firmware: timeout',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// window.open mock — JSDOM 預設有 window.open、但回傳 null(不會真開新分頁)。
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
describe('FirmwareErrorView — destructive reasons (brick warning + ContactSupport)', () => {
|
||||
const destructiveReasons: FirmwareReason[] = [
|
||||
'disconnect_during_op',
|
||||
'verify_mismatch',
|
||||
'verify_not_found',
|
||||
];
|
||||
|
||||
it.each(destructiveReasons)(
|
||||
'reason=%s 顯示 brick warning + ContactSupport(不顯示 Retry)',
|
||||
(reason) => {
|
||||
const onClose = vi.fn();
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<FirmwareErrorView
|
||||
progress={makeEvent({ reason })}
|
||||
onRetry={onRetry}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// brick warning(role="note")
|
||||
const note = screen.getByRole('note');
|
||||
expect(note.textContent).toMatch(/損毀|damaged/i);
|
||||
|
||||
// 不顯示 Retry / ReplugRetry / Rescan 按鈕
|
||||
// (ContactSupport 為 destructive variant、唯一 primary action)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const labels = buttons.map((b) => b.textContent || '');
|
||||
expect(labels.some((l) => /Retry|重試/.test(l))).toBe(false);
|
||||
expect(labels.some((l) => /Contact|聯絡技術支援|Support/.test(l))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('ContactSupport 按鈕點擊會開 mailto: handler(不再 disabled)', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<FirmwareErrorView
|
||||
progress={makeEvent({ reason: 'verify_mismatch' })}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const contactBtn = screen.getByRole('button', { name: /Contact|聯絡技術支援|Support/ });
|
||||
expect((contactBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(contactBtn);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
const [href] = (window.open as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(typeof href).toBe('string');
|
||||
expect(href).toMatch(/^mailto:/);
|
||||
// body 應該帶上技術資訊
|
||||
expect(decodeURIComponent(href)).toContain('stage: error');
|
||||
expect(decodeURIComponent(href)).toContain('reason: verify_mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FirmwareErrorView — recoverable reasons show Retry button', () => {
|
||||
it('reason=connect_failed 顯示 Retry 按鈕、不顯示 brick warning', () => {
|
||||
const onClose = vi.fn();
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<FirmwareErrorView
|
||||
progress={makeEvent({ reason: 'connect_failed' })}
|
||||
onRetry={onRetry}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 沒有 brick warning role=note
|
||||
expect(screen.queryByRole('note')).toBeNull();
|
||||
|
||||
// 顯示 Retry primary action 按鈕
|
||||
const retryBtn = screen.getByRole('button', { name: /Retry|重試|插拔/ });
|
||||
expect(retryBtn).toBeTruthy();
|
||||
fireEvent.click(retryBtn);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reason=scan_not_found 顯示 ReplugRetry 動作', () => {
|
||||
const onClose = vi.fn();
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<FirmwareErrorView
|
||||
progress={makeEvent({ reason: 'scan_not_found' })}
|
||||
onRetry={onRetry}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: /插拔|Unplug and retry/ });
|
||||
expect(btn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FirmwareErrorView — common UI elements', () => {
|
||||
it('顯示 errorCode(mono 字型小字)', () => {
|
||||
render(
|
||||
<FirmwareErrorView
|
||||
progress={makeEvent({ errorCode: 'FW_E102', reason: 'upgrade_mid_failed' })}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// errorCode 同時出現在「錯誤代碼」<p> 與技術資訊 <pre>、用 getAllByText 確保至少 1 個。
|
||||
expect(screen.getAllByText(/FW_E102/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('技術資訊 collapsible 預設收合', () => {
|
||||
const { container } = render(
|
||||
<FirmwareErrorView progress={makeEvent({ reason: 'connect_failed' })} onClose={vi.fn()} />,
|
||||
);
|
||||
const details = container.querySelector('details');
|
||||
expect(details).not.toBeNull();
|
||||
expect((details as HTMLDetailsElement).open).toBe(false);
|
||||
});
|
||||
|
||||
it('Close 按鈕觸發 onClose callback', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<FirmwareErrorView
|
||||
progress={makeEvent({ reason: 'connect_failed' })}
|
||||
onRetry={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
const closeBtn = screen.getByRole('button', { name: /Close|關閉/ });
|
||||
fireEvent.click(closeBtn);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
268
local-tool/frontend/src/tests/stores/firmware-store.test.ts
Normal file
268
local-tool/frontend/src/tests/stores/firmware-store.test.ts
Normal file
@ -0,0 +1,268 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
useFirmwareStore,
|
||||
errorMessageKeyFor,
|
||||
canRetryReason,
|
||||
primaryActionKeyFor,
|
||||
estimatedDurationSeconds,
|
||||
stageOrdinal,
|
||||
} from '@/stores/firmware-store';
|
||||
import type { FirmwareProgressEvent } from '@/types/device';
|
||||
|
||||
/**
|
||||
* M9-4 firmware-store 測試。
|
||||
* 覆蓋:
|
||||
* - phase transition(idle → confirming → upgrading → success / error)
|
||||
* - 多裝置隔離(其他 deviceId 的 event 不更新 active state)
|
||||
* - cancelConfirm 只在 confirming 階段允許
|
||||
* - 各 helper(errorMessageKeyFor / canRetryReason / primaryActionKeyFor /
|
||||
* estimatedDurationSeconds / stageOrdinal)對齊 Design §7.1 + AC-FW-1.7
|
||||
*/
|
||||
|
||||
function resetStore() {
|
||||
useFirmwareStore.setState({
|
||||
activeDeviceId: null,
|
||||
activeTaskId: null,
|
||||
phase: 'idle',
|
||||
progress: null,
|
||||
beforeVersion: null,
|
||||
targetVersion: null,
|
||||
startedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useFirmwareStore — phase transitions', () => {
|
||||
beforeEach(resetStore);
|
||||
|
||||
it('starts in idle', () => {
|
||||
expect(useFirmwareStore.getState().phase).toBe('idle');
|
||||
expect(useFirmwareStore.getState().activeDeviceId).toBeNull();
|
||||
});
|
||||
|
||||
it('startConfirm enters confirming with device + versions recorded', () => {
|
||||
useFirmwareStore.getState().startConfirm('dev-1', 'KDP1', 'v2.2.0');
|
||||
const s = useFirmwareStore.getState();
|
||||
expect(s.phase).toBe('confirming');
|
||||
expect(s.activeDeviceId).toBe('dev-1');
|
||||
expect(s.beforeVersion).toBe('KDP1');
|
||||
expect(s.targetVersion).toBe('v2.2.0');
|
||||
});
|
||||
|
||||
it('cancelConfirm resets to idle from confirming', () => {
|
||||
useFirmwareStore.getState().startConfirm('dev-1', 'KDP1', 'v2.2.0');
|
||||
useFirmwareStore.getState().cancelConfirm();
|
||||
expect(useFirmwareStore.getState().phase).toBe('idle');
|
||||
expect(useFirmwareStore.getState().activeDeviceId).toBeNull();
|
||||
});
|
||||
|
||||
it('cancelConfirm is no-op when phase is upgrading (R-FW-11 mitigation)', () => {
|
||||
useFirmwareStore.setState({
|
||||
activeDeviceId: 'dev-1',
|
||||
phase: 'upgrading',
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
useFirmwareStore.getState().cancelConfirm();
|
||||
expect(useFirmwareStore.getState().phase).toBe('upgrading');
|
||||
expect(useFirmwareStore.getState().activeDeviceId).toBe('dev-1');
|
||||
});
|
||||
|
||||
it('handleEvent transitions to success on stage=done', () => {
|
||||
useFirmwareStore.setState({
|
||||
activeDeviceId: 'dev-1',
|
||||
phase: 'upgrading',
|
||||
});
|
||||
const ev: FirmwareProgressEvent = {
|
||||
type: 'firmware_progress',
|
||||
deviceId: 'dev-1',
|
||||
stage: 'done',
|
||||
percent: 100,
|
||||
elapsedMs: 28000,
|
||||
afterVersion: 'v2.2.0',
|
||||
};
|
||||
useFirmwareStore.getState().handleEvent(ev);
|
||||
const s = useFirmwareStore.getState();
|
||||
expect(s.phase).toBe('success');
|
||||
expect(s.progress?.afterVersion).toBe('v2.2.0');
|
||||
});
|
||||
|
||||
it('handleEvent transitions to error on stage=error', () => {
|
||||
useFirmwareStore.setState({
|
||||
activeDeviceId: 'dev-1',
|
||||
phase: 'upgrading',
|
||||
});
|
||||
const ev: FirmwareProgressEvent = {
|
||||
type: 'firmware_progress',
|
||||
deviceId: 'dev-1',
|
||||
stage: 'error',
|
||||
percent: -1,
|
||||
elapsedMs: 12000,
|
||||
error: 'connect failed',
|
||||
reason: 'connect_failed',
|
||||
};
|
||||
useFirmwareStore.getState().handleEvent(ev);
|
||||
expect(useFirmwareStore.getState().phase).toBe('error');
|
||||
});
|
||||
|
||||
it('handleEvent keeps phase=upgrading during intermediate stages', () => {
|
||||
useFirmwareStore.setState({
|
||||
activeDeviceId: 'dev-1',
|
||||
phase: 'upgrading',
|
||||
});
|
||||
for (const stage of ['preparing', 'loading', 'flashing', 'verifying'] as const) {
|
||||
useFirmwareStore.getState().handleEvent({
|
||||
type: 'firmware_progress',
|
||||
deviceId: 'dev-1',
|
||||
stage,
|
||||
percent: 25,
|
||||
elapsedMs: 5000,
|
||||
});
|
||||
expect(useFirmwareStore.getState().phase).toBe('upgrading');
|
||||
}
|
||||
});
|
||||
|
||||
it('handleEvent ignores events from other devices (multi-device isolation)', () => {
|
||||
useFirmwareStore.setState({
|
||||
activeDeviceId: 'dev-A',
|
||||
phase: 'upgrading',
|
||||
});
|
||||
useFirmwareStore.getState().handleEvent({
|
||||
type: 'firmware_progress',
|
||||
deviceId: 'dev-B', // ← 不同 device
|
||||
stage: 'error',
|
||||
percent: -1,
|
||||
elapsedMs: 1000,
|
||||
reason: 'connect_failed',
|
||||
});
|
||||
// phase 仍為 upgrading、progress 沒被覆蓋
|
||||
expect(useFirmwareStore.getState().phase).toBe('upgrading');
|
||||
expect(useFirmwareStore.getState().progress).toBeNull();
|
||||
});
|
||||
|
||||
it('reset returns to idle and clears all device context', () => {
|
||||
useFirmwareStore.setState({
|
||||
activeDeviceId: 'dev-1',
|
||||
phase: 'error',
|
||||
progress: {
|
||||
type: 'firmware_progress',
|
||||
deviceId: 'dev-1',
|
||||
stage: 'error',
|
||||
percent: -1,
|
||||
elapsedMs: 0,
|
||||
},
|
||||
beforeVersion: 'KDP1',
|
||||
targetVersion: 'v2.2.0',
|
||||
});
|
||||
useFirmwareStore.getState().reset();
|
||||
const s = useFirmwareStore.getState();
|
||||
expect(s.phase).toBe('idle');
|
||||
expect(s.activeDeviceId).toBeNull();
|
||||
expect(s.progress).toBeNull();
|
||||
expect(s.beforeVersion).toBeNull();
|
||||
expect(s.targetVersion).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorMessageKeyFor — reason → i18n key (Design §7.1)', () => {
|
||||
it('maps each reason to its specific message key', () => {
|
||||
expect(errorMessageKeyFor('preparing', 'scan_not_found')).toBe(
|
||||
'settings.firmware.error.message.scanNotFound',
|
||||
);
|
||||
expect(errorMessageKeyFor('preparing', 'connect_failed')).toBe(
|
||||
'settings.firmware.error.message.connectFailed',
|
||||
);
|
||||
expect(errorMessageKeyFor('loading', 'loader_write_failed')).toBe(
|
||||
'settings.firmware.error.message.loaderWriteFailed',
|
||||
);
|
||||
expect(errorMessageKeyFor('flashing', 'upgrade_mid_failed')).toBe(
|
||||
'settings.firmware.error.message.upgradeMidFailed',
|
||||
);
|
||||
expect(errorMessageKeyFor('verifying', 'verify_mismatch')).toBe(
|
||||
'settings.firmware.error.message.verifyMismatch',
|
||||
);
|
||||
expect(errorMessageKeyFor('verifying', 'verify_not_found')).toBe(
|
||||
'settings.firmware.error.message.verifyNotFound',
|
||||
);
|
||||
expect(errorMessageKeyFor('flashing', 'timeout')).toBe(
|
||||
'settings.firmware.error.message.timeout',
|
||||
);
|
||||
expect(errorMessageKeyFor('flashing', 'disconnect_during_op')).toBe(
|
||||
'settings.firmware.error.message.disconnect',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back by stage when reason is undefined', () => {
|
||||
expect(errorMessageKeyFor('preparing')).toBe('settings.firmware.error.message.connectFailed');
|
||||
expect(errorMessageKeyFor('loading')).toBe('settings.firmware.error.message.loaderWriteFailed');
|
||||
expect(errorMessageKeyFor('flashing')).toBe('settings.firmware.error.message.upgradeMidFailed');
|
||||
expect(errorMessageKeyFor('verifying')).toBe('settings.firmware.error.message.verifyMismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRetryReason — destructive failures cannot retry', () => {
|
||||
it('destructive reasons return false', () => {
|
||||
expect(canRetryReason('disconnect_during_op')).toBe(false);
|
||||
expect(canRetryReason('verify_mismatch')).toBe(false);
|
||||
expect(canRetryReason('verify_not_found')).toBe(false);
|
||||
});
|
||||
|
||||
it('recoverable reasons return true', () => {
|
||||
expect(canRetryReason('scan_not_found')).toBe(true);
|
||||
expect(canRetryReason('connect_failed')).toBe(true);
|
||||
expect(canRetryReason('loader_write_failed')).toBe(true);
|
||||
expect(canRetryReason('upgrade_mid_failed')).toBe(true);
|
||||
expect(canRetryReason('timeout')).toBe(true);
|
||||
});
|
||||
|
||||
it('undefined reason defaults to retryable', () => {
|
||||
expect(canRetryReason(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('primaryActionKeyFor — recommended action mapping', () => {
|
||||
it('replug+retry for scan_not_found / disconnect', () => {
|
||||
expect(primaryActionKeyFor('scan_not_found')).toBe('settings.firmware.error.action.replugRetry');
|
||||
expect(primaryActionKeyFor('disconnect_during_op')).toBe('settings.firmware.error.action.replugRetry');
|
||||
});
|
||||
it('rescan for verify failures / timeout', () => {
|
||||
expect(primaryActionKeyFor('verify_mismatch')).toBe('settings.firmware.error.action.rescan');
|
||||
expect(primaryActionKeyFor('verify_not_found')).toBe('settings.firmware.error.action.rescan');
|
||||
expect(primaryActionKeyFor('timeout')).toBe('settings.firmware.error.action.rescan');
|
||||
});
|
||||
it('plain retry for general failures', () => {
|
||||
expect(primaryActionKeyFor('connect_failed')).toBe('settings.firmware.error.action.retry');
|
||||
expect(primaryActionKeyFor('loader_write_failed')).toBe('settings.firmware.error.action.retry');
|
||||
expect(primaryActionKeyFor('upgrade_mid_failed')).toBe('settings.firmware.error.action.retry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimatedDurationSeconds — chip-specific estimates (AC-FW-1.7)', () => {
|
||||
it('KL520 ~ 30s', () => {
|
||||
expect(estimatedDurationSeconds('kneron_kl520')).toBe(30);
|
||||
expect(estimatedDurationSeconds('KL520')).toBe(30);
|
||||
});
|
||||
it('KL720 ~ 180s', () => {
|
||||
expect(estimatedDurationSeconds('kneron_kl720')).toBe(180);
|
||||
expect(estimatedDurationSeconds('KL720')).toBe(180);
|
||||
});
|
||||
it('fallback semantics: undefined → 60s(保守、不知 chip); 認不出的 type → 30s(預設 KL520 路徑)', () => {
|
||||
// 兩個 fallback 數值不同是刻意的(M3 釐清):
|
||||
// - undefined:caller 沒拿到 chip 資訊 → 保守值 60s
|
||||
// - 有 type 但 toLowerCase 後不含 kl720 → KL520 路徑 30s
|
||||
expect(estimatedDurationSeconds(undefined)).toBe(60);
|
||||
expect(estimatedDurationSeconds('unknown_device')).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageOrdinal — 4-stage vs 3-stage upgrade', () => {
|
||||
it('legacy upgrade (KDP1→KDP2) has 4 stages', () => {
|
||||
expect(stageOrdinal('preparing', true)).toEqual({ n: 1, total: 4 });
|
||||
expect(stageOrdinal('loading', true)).toEqual({ n: 2, total: 4 });
|
||||
expect(stageOrdinal('flashing', true)).toEqual({ n: 3, total: 4 });
|
||||
expect(stageOrdinal('verifying', true)).toEqual({ n: 4, total: 4 });
|
||||
});
|
||||
it('same-gen upgrade (KDP2→KDP2) has 3 stages', () => {
|
||||
expect(stageOrdinal('preparing', false)).toEqual({ n: 1, total: 3 });
|
||||
expect(stageOrdinal('flashing', false)).toEqual({ n: 2, total: 3 });
|
||||
expect(stageOrdinal('verifying', false)).toEqual({ n: 3, total: 3 });
|
||||
});
|
||||
});
|
||||
@ -8,6 +8,10 @@ export interface Device {
|
||||
status: DeviceStatus;
|
||||
firmwareVersion?: string;
|
||||
flashedModel?: string;
|
||||
// M9-3 backend 新增衍生欄位(TDD §3.1)— 用來決定 FW badge 與升級按鈕顯示。
|
||||
firmwareIsLegacy?: boolean;
|
||||
firmwareCanUpgrade?: boolean;
|
||||
bundledFirmwareVersion?: string;
|
||||
}
|
||||
|
||||
export interface DeviceEvent {
|
||||
@ -23,3 +27,63 @@ export interface FlashProgress {
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// FW badge 三色(含 unknown 灰、AC-FW-1.1)。
|
||||
export type FirmwareBadgeState = 'current' | 'older' | 'legacy' | 'unknown';
|
||||
|
||||
// FirmwareStage — 對齊 backend `FirmwareProgress.Stage`(TDD §4.3、Design §8)。
|
||||
export type FirmwareStage =
|
||||
| 'preparing'
|
||||
| 'loading'
|
||||
| 'flashing'
|
||||
| 'verifying'
|
||||
| 'done'
|
||||
| 'error';
|
||||
|
||||
// FirmwareReason — 對齊 backend `FirmwareProgress.Reason`(TDD §3.4)。
|
||||
export type FirmwareReason =
|
||||
| 'scan_not_found'
|
||||
| 'connect_failed'
|
||||
| 'loader_write_failed'
|
||||
| 'upgrade_mid_failed'
|
||||
| 'disconnect_during_op'
|
||||
| 'timeout'
|
||||
| 'verify_mismatch'
|
||||
| 'verify_not_found'
|
||||
| 'validate_failed'; // downgrade 專屬(B2、保留)
|
||||
|
||||
// FirmwareProgressEvent — WS broadcast schema(backend `firmware_handler.go`
|
||||
// `firmwareProgressMessage`:type 標籤 + 平坦展開 FirmwareProgress 欄位)。
|
||||
export interface FirmwareProgressEvent {
|
||||
type: 'firmware_progress';
|
||||
deviceId: string;
|
||||
stage: FirmwareStage;
|
||||
percent: number; // 0-100、-1 表 error
|
||||
direction?: 'upgrade' | 'downgrade';
|
||||
message?: string;
|
||||
elapsedMs: number;
|
||||
etaMs?: number;
|
||||
// 失敗欄位(stage === 'error')
|
||||
error?: string;
|
||||
reason?: FirmwareReason;
|
||||
rawError?: string;
|
||||
beforeVersion?: string;
|
||||
errorCode?: string;
|
||||
// 成功 done 欄位
|
||||
afterVersion?: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
// FirmwareActiveTask — GET /api/firmware/active-tasks response item
|
||||
// (backend `firmware.ActiveTaskInfo`、給 Wails OnBeforeClose 偵測用)。
|
||||
export interface FirmwareActiveTask {
|
||||
taskId: string;
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
chip: string;
|
||||
direction: 'upgrade' | 'downgrade';
|
||||
stage: FirmwareStage;
|
||||
startTs: string; // ISO 8601
|
||||
elapsedMs: number;
|
||||
etaSeconds?: number;
|
||||
}
|
||||
|
||||
@ -121,6 +121,8 @@ func NewRouter(
|
||||
// WebSocket
|
||||
r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr))
|
||||
r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub))
|
||||
// M9-4 hot-fix:firmware 升降版進度(room "firmware:<id>"),對稱 flash-progress。
|
||||
r.GET("/ws/devices/:id/firmware-progress", ws.FirmwareProgressHandler(wsHub))
|
||||
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
|
||||
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
|
||||
// MAJ-4 補丁:/ws/system — server:shutdown-imminent 事件訂閱
|
||||
|
||||
50
local-tool/server/internal/api/ws/firmware_ws.go
Normal file
50
local-tool/server/internal/api/ws/firmware_ws.go
Normal file
@ -0,0 +1,50 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// FirmwareProgressHandler — M9-4 hot-fix。
|
||||
//
|
||||
// 對稱於 FlashProgressHandler。Client 連 /ws/devices/:id/firmware-progress
|
||||
// 後會被 join 到 room "firmware:<deviceID>",由
|
||||
// firmware_handler.forwardProgressToWS 透過 hub.BroadcastToRoom 推進度。
|
||||
//
|
||||
// 行為與 flash 版完全一致:
|
||||
// - 共用 package-level upgrader(CheckOrigin = loopback 白名單,見 device_events_ws.go)
|
||||
// - 用 RegisterSync 確保 client 已 join room 才回到 read/write loop
|
||||
// - 一個 goroutine drain client 端 incoming 訊息(純為觸發斷線偵測)
|
||||
// - 主 goroutine 把 client.Send channel 的訊息 WriteMessage 到 conn
|
||||
// - conn / unregister 統一由 defer 處理
|
||||
func FirmwareProgressHandler(hub *Hub) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceID := c.Param("id")
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := &Client{Conn: conn, Send: make(chan []byte, 20)}
|
||||
room := "firmware:" + deviceID
|
||||
sub := &Subscription{Client: client, Room: room}
|
||||
hub.RegisterSync(sub)
|
||||
defer hub.Unregister(sub)
|
||||
|
||||
// Read pump — drain incoming messages; close handled by outer defer
|
||||
go func() {
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for msg := range client.Send {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
local-tool/server/internal/api/ws/firmware_ws_test.go
Normal file
165
local-tool/server/internal/api/ws/firmware_ws_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package ws
|
||||
|
||||
// firmware_ws_test.go — M9-4 hot-fix smoke test
|
||||
//
|
||||
// 對稱 system_ws_integration_test.go:啟 httptest server 掛
|
||||
// FirmwareProgressHandler,用 gorilla WebSocket client 真的連線進去,
|
||||
// 然後 hub.BroadcastToRoom("firmware:<deviceID>", ...) 驗證 client 收到。
|
||||
//
|
||||
// 目的:保證 /ws/devices/:id/firmware-progress endpoint 把 client 正確
|
||||
// join 到 "firmware:<deviceID>" room、且 broadcast 能送達。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestFirmwareProgressHandler_ReceivesBroadcast(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/ws/devices/:id/firmware-progress", FirmwareProgressHandler(hub))
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
const deviceID = "kl520-0"
|
||||
room := "firmware:" + deviceID
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/devices/" + deviceID + "/firmware-progress"
|
||||
dialer := websocket.DefaultDialer
|
||||
conn, _, err := dialer.Dial(wsURL, http.Header{})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
// 等 hub 吸收 Register(最多 500 ms)
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
hub.mu.RLock()
|
||||
n := len(hub.rooms[room])
|
||||
hub.mu.RUnlock()
|
||||
if n > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 廣播 firmware progress 事件
|
||||
hub.BroadcastToRoom(room, map[string]interface{}{
|
||||
"type": "firmware:progress",
|
||||
"deviceId": deviceID,
|
||||
"phase": "flashing",
|
||||
"percent": 42,
|
||||
})
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("json: %v; raw=%s", err, string(data))
|
||||
}
|
||||
if got["type"] != "firmware:progress" {
|
||||
t.Errorf("wrong type: %+v", got)
|
||||
}
|
||||
if got["deviceId"] != deviceID {
|
||||
t.Errorf("wrong deviceId: %+v", got)
|
||||
}
|
||||
if got["phase"] != "flashing" {
|
||||
t.Errorf("wrong phase: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirmwareProgressHandler_RoomIsolation 驗證不同 deviceID 的 client
|
||||
// 不會收到對方 room 的訊息(room key 帶 deviceID 的關鍵保證)。
|
||||
func TestFirmwareProgressHandler_RoomIsolation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/ws/devices/:id/firmware-progress", FirmwareProgressHandler(hub))
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
dialFor := func(deviceID string) *websocket.Conn {
|
||||
t.Helper()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") +
|
||||
"/ws/devices/" + deviceID + "/firmware-progress"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{})
|
||||
if err != nil {
|
||||
t.Fatalf("dial %s: %v", deviceID, err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
connA := dialFor("kl520-0")
|
||||
defer connA.Close()
|
||||
connB := dialFor("kl720-1")
|
||||
defer connB.Close()
|
||||
|
||||
// 等兩個 room 都被 hub 吸收
|
||||
wantRooms := []string{"firmware:kl520-0", "firmware:kl720-1"}
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
ok := true
|
||||
hub.mu.RLock()
|
||||
for _, room := range wantRooms {
|
||||
if len(hub.rooms[room]) == 0 {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
hub.mu.RUnlock()
|
||||
if ok {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 只 broadcast 到 kl520-0 的 room
|
||||
hub.BroadcastToRoom("firmware:kl520-0", map[string]interface{}{
|
||||
"type": "firmware:progress",
|
||||
"deviceId": "kl520-0",
|
||||
"percent": 10,
|
||||
})
|
||||
|
||||
// connA 應該收到
|
||||
_ = connA.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||
_, data, err := connA.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("connA read: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("connA json: %v", err)
|
||||
}
|
||||
if got["deviceId"] != "kl520-0" {
|
||||
t.Errorf("connA got wrong deviceId: %+v", got)
|
||||
}
|
||||
|
||||
// connB 不該收到 — 設短 deadline,預期 timeout
|
||||
_ = connB.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
_, _, err = connB.ReadMessage()
|
||||
if err == nil {
|
||||
t.Errorf("connB should not have received message but did")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user