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>
274 lines
9.8 KiB
TypeScript
274 lines
9.8 KiB
TypeScript
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: [] };
|
||
}
|