diff --git a/local-tool/.autoflow/05-implementation/review/m9-4-frontend-firmware-review-round2.md b/local-tool/.autoflow/05-implementation/review/m9-4-frontend-firmware-review-round2.md new file mode 100644 index 0000000..8409cbe --- /dev/null +++ b/local-tool/.autoflow/05-implementation/review/m9-4-frontend-firmware-review-round2.md @@ -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 直接 `` 無 `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 + `
` + 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`(同時在 `

` 與 `

` 出現、刻意接受兩處)| 中-高 — 接受重複出現是對的、若用 `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) `
` 區 / (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 `` 無 `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 / `
` / 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)。
diff --git a/local-tool/.autoflow/05-implementation/review/m9-4-frontend-firmware-review.md b/local-tool/.autoflow/05-implementation/review/m9-4-frontend-firmware-review.md
new file mode 100644
index 0000000..34e12b7
--- /dev/null
+++ b/local-tool/.autoflow/05-implementation/review/m9-4-frontend-firmware-review.md
@@ -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` `
` 元素 | 完全 | 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:` | ✅ | `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` 用 `
{...template literal}` 渲染、React 自動 escape、無 dangerouslySetInnerHTML(已 grep `dangerouslySetInnerHTML / v-html`:零 hit)
+- ✅ **raw error 不在主畫面**:rawError 只出現在 collapsible `
` 內(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 共同裁決):
**A.** 把 i18n keys 搬到 `settings.firmware.*` namespace 下(雖然 A 階段不在 settings 頁、但對齊 Design 文件):例如 `firmware.error.messageScanNotFound` → `settings.firmware.error.message.scanNotFound`
**B.** 維持 `firmware.*` flat 結構,但回頭請 Design Agent 修 Design Spec §9 把 namespace 改為與本批一致,明確說明「i18n key 與分頁位置解耦」
**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 完整性原則 | +| 問題描述 | ` + + +
+ + {allowRetry && onRetry && ( + + )} + {!allowRetry && ( + + )} +
+ + ); +} diff --git a/local-tool/frontend/src/components/firmware/firmware-progress-view.tsx b/local-tool/frontend/src/components/firmware/firmware-progress-view.tsx new file mode 100644 index 0000000..6f3e50f --- /dev/null +++ b/local-tool/frontend/src/components/firmware/firmware-progress-view.tsx @@ -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 ( +
+
+ + {t('settings.firmware.progress.stage.preparing', { n: 1, total: isLegacyUpgrade ? 4 : 3 })} +
+ +
+ ); + } + + 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 ( +
+ {/* 階段 + 百分比 */} +
+ + {progress.stage === 'done' + ? t('settings.firmware.progress.stage.done') + : t(stageKey, { n, total })} + + {percent}% +
+ + {/* Progress bar */} + + + {/* 計時 */} +
+ {t('settings.firmware.progress.elapsed', { seconds: elapsedSec })} + {etaSec !== null && progress.stage !== 'done' && ( + {t('settings.firmware.progress.estimatedRemaining', { seconds: etaSec })} + )} +
+ + {/* 紅色警告 banner — R-FW-11 緩解 */} + {progress.stage !== 'done' && ( +
+ {t('settings.firmware.progress.warningUpgrade')} +
+ )} +
+ ); +} diff --git a/local-tool/frontend/src/components/firmware/firmware-upgrade-button.tsx b/local-tool/frontend/src/components/firmware/firmware-upgrade-button.tsx new file mode 100644 index 0000000..b32fcad --- /dev/null +++ b/local-tool/frontend/src/components/firmware/firmware-upgrade-button.tsx @@ -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 ( + <> + + + + + ); +} diff --git a/local-tool/frontend/src/components/firmware/firmware-upgrade-dialog.tsx b/local-tool/frontend/src/components/firmware/firmware-upgrade-dialog.tsx new file mode 100644 index 0000000..1dca07a --- /dev/null +++ b/local-tool/frontend/src/components/firmware/firmware-upgrade-dialog.tsx @@ -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 ( + { + // R-FW-11:升級進行中拒絕關閉 + if (!next && isInProgress) return; + if (!next) handleClose(); + else onOpenChange(true); + }} + > + { + if (isInProgress) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (isInProgress) e.preventDefault(); + }} + > + + {t('settings.firmware.upgradeModal.title')} + + {t('settings.firmware.upgradeModal.heading', { deviceName: device.name })} + + + + {/* Phase: confirming */} + {phase === 'confirming' && ( +
+
+
+

{t('settings.firmware.upgradeModal.from')}

+

{beforeVer}

+
+
+

{t('settings.firmware.upgradeModal.to')}

+

{targetVer}

+
+
+
+ {t('settings.firmware.upgradeModal.warning', { duration: estimatedLabel })} +
+ + + + +
+ )} + + {/* Phase: upgrading */} + {phase === 'upgrading' && ( + + )} + + {/* Phase: success — 短暫顯示綠勾、由 useEffect 自動關閉 */} + {phase === 'success' && progress && ( +
+

+ ✓ {t('settings.firmware.progress.stage.done')} +

+

+ {progress.afterVersion || targetVer} +

+
+ )} + + {/* Phase: error */} + {phase === 'error' && progress && ( + + )} +
+
+ ); +} diff --git a/local-tool/frontend/src/components/firmware/index.ts b/local-tool/frontend/src/components/firmware/index.ts new file mode 100644 index 0000000..e05b23c --- /dev/null +++ b/local-tool/frontend/src/components/firmware/index.ts @@ -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'; diff --git a/local-tool/frontend/src/hooks/use-firmware-progress.ts b/local-tool/frontend/src/hooks/use-firmware-progress.ts new file mode 100644 index 0000000..eb928b7 --- /dev/null +++ b/local-tool/frontend/src/hooks/use-firmware-progress.ts @@ -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:`)。 + * + * 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 | null>(null); + + // Cleanup on unmount / deviceId change + useEffect(() => { + return () => { + wsRef.current?.close(); + wsRef.current = null; + }; + }, [deviceId]); + + const connectAndWait = useCallback( + () => + new Promise((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 }; +} diff --git a/local-tool/frontend/src/lib/i18n/en.ts b/local-tool/frontend/src/lib/i18n/en.ts index bb4b0af..64c4fd4 100644 --- a/local-tool/frontend/src/lib/i18n/en.ts +++ b/local-tool/frontend/src/lib/i18n/en.ts @@ -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', diff --git a/local-tool/frontend/src/lib/i18n/types.ts b/local-tool/frontend/src/lib/i18n/types.ts index 4452c45..75df5e3 100644 --- a/local-tool/frontend/src/lib/i18n/types.ts +++ b/local-tool/frontend/src/lib/i18n/types.ts @@ -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; diff --git a/local-tool/frontend/src/lib/i18n/zh-TW.ts b/local-tool/frontend/src/lib/i18n/zh-TW.ts index 521e53d..a48c393 100644 --- a/local-tool/frontend/src/lib/i18n/zh-TW.ts +++ b/local-tool/frontend/src/lib/i18n/zh-TW.ts @@ -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', diff --git a/local-tool/frontend/src/lib/toast.ts b/local-tool/frontend/src/lib/toast.ts index 75c3cdd..1341d8d 100644 --- a/local-tool/frontend/src/lib/toast.ts +++ b/local-tool/frontend/src/lib/toast.ts @@ -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 }) { diff --git a/local-tool/frontend/src/stores/firmware-store.ts b/local-tool/frontend/src/stores/firmware-store.ts new file mode 100644 index 0000000..4bcb35b --- /dev/null +++ b/local-tool/frontend/src/stores/firmware-store.ts @@ -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((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(`/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('/firmware/active-tasks'); + if (res.success && res.data) { + return { hasActive: !!res.data.hasActive, tasks: res.data.tasks || [] }; + } + return { hasActive: false, tasks: [] }; +} diff --git a/local-tool/frontend/src/tests/components/firmware-badge.test.tsx b/local-tool/frontend/src/tests/components/firmware-badge.test.tsx new file mode 100644 index 0000000..3a16e8a --- /dev/null +++ b/local-tool/frontend/src/tests/components/firmware-badge.test.tsx @@ -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 { + 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(); + 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( + , + ); + 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(); + const badge = screen.getByTestId('firmware-badge'); + expect(badge.getAttribute('data-state')).toBe('unknown'); + }); + + it('has aria-label and title (tooltip)', () => { + render(); + const badge = screen.getByTestId('firmware-badge'); + expect(badge.getAttribute('aria-label')).toBeTruthy(); + expect(badge.getAttribute('title')).toBeTruthy(); + }); +}); diff --git a/local-tool/frontend/src/tests/components/firmware-error-view.test.tsx b/local-tool/frontend/src/tests/components/firmware-error-view.test.tsx new file mode 100644 index 0000000..bb2277f --- /dev/null +++ b/local-tool/frontend/src/tests/components/firmware-error-view.test.tsx @@ -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 { + 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( + , + ); + + // 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( + , + ); + + 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).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( + , + ); + + // 沒有 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( + , + ); + const btn = screen.getByRole('button', { name: /插拔|Unplug and retry/ }); + expect(btn).toBeTruthy(); + }); +}); + +describe('FirmwareErrorView — common UI elements', () => { + it('顯示 errorCode(mono 字型小字)', () => { + render( + , + ); + // errorCode 同時出現在「錯誤代碼」

與技術資訊

、用 getAllByText 確保至少 1 個。
+    expect(screen.getAllByText(/FW_E102/).length).toBeGreaterThanOrEqual(1);
+  });
+
+  it('技術資訊 collapsible 預設收合', () => {
+    const { container } = render(
+      ,
+    );
+    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(
+      ,
+    );
+    const closeBtn = screen.getByRole('button', { name: /Close|關閉/ });
+    fireEvent.click(closeBtn);
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/local-tool/frontend/src/tests/stores/firmware-store.test.ts b/local-tool/frontend/src/tests/stores/firmware-store.test.ts
new file mode 100644
index 0000000..ee25bb4
--- /dev/null
+++ b/local-tool/frontend/src/tests/stores/firmware-store.test.ts
@@ -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 });
+  });
+});
diff --git a/local-tool/frontend/src/types/device.ts b/local-tool/frontend/src/types/device.ts
index 8ac0d11..f2db502 100644
--- a/local-tool/frontend/src/types/device.ts
+++ b/local-tool/frontend/src/types/device.ts
@@ -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;
+}
diff --git a/local-tool/server/internal/api/router.go b/local-tool/server/internal/api/router.go
index 7da7238..fcaa4ac 100644
--- a/local-tool/server/internal/api/router.go
+++ b/local-tool/server/internal/api/router.go
@@ -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:"),對稱 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 事件訂閱
diff --git a/local-tool/server/internal/api/ws/firmware_ws.go b/local-tool/server/internal/api/ws/firmware_ws.go
new file mode 100644
index 0000000..dceddc4
--- /dev/null
+++ b/local-tool/server/internal/api/ws/firmware_ws.go
@@ -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:",由
+// 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
+			}
+		}
+	}
+}
diff --git a/local-tool/server/internal/api/ws/firmware_ws_test.go b/local-tool/server/internal/api/ws/firmware_ws_test.go
new file mode 100644
index 0000000..71d8c44
--- /dev/null
+++ b/local-tool/server/internal/api/ws/firmware_ws_test.go
@@ -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:", ...) 驗證 client 收到。
+//
+// 目的:保證 /ws/devices/:id/firmware-progress endpoint 把 client 正確
+// join 到 "firmware:" 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")
+	}
+}