- DeviceSummary 加 serialNumber(serial_number/serialNumber 雙 key 容錯、 缺欄/空字串 → null) - 五個 proxy 操作識別值換 serial:connect/disconnect、flash POST+進度 WS (單一派生點保 room key 一致)、inference WS、camera start/stop+WS、 media 三 upload - serial 為空全面 disable + 提示(workspace banner/media 占位/flash-dialog/ device-card tooltip/device-detail 兩鈕+序號 InfoRow/選擇頁);i18n 兩語系 - UUID 組維持:fetchDevices/fetchDevice/unpair/詳情頁路由 - C5 偏差(路由段維持 UUID):與 FE-A 定案一致、reviewer 獨立驗證成立 (API 層無 GetBySerial、帶 serial deep-link 必 404);文件回填另派 - evidence:tsc 0 / eslint 0 / next build 15 route / 觸及 11 測試檔 99 passed - review:通過 0C/0M/3Mi/2Sug(.autoflow/05-implementation/review/ wp-c-frontend-serial-routing-review.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
111 lines
4.8 KiB
TypeScript
111 lines
4.8 KiB
TypeScript
/**
|
||
* useFlashProgress — flash 進度 WS 訂閱 + 順序保證的 POST 觸發(塊:flash UI)
|
||
*
|
||
* 移植自 POC edge-ai-platform/frontend/src/hooks/use-flash-progress.ts(唯讀參考、非搬 code)。
|
||
*
|
||
* ⚠️ 最關鍵的契約差異(見 flash-model-load-mapping.md §2.3-B,採「選項 2」):
|
||
* - POC `useFlashProgress` 提供 imperative `connectAndWait(): Promise<void>`,
|
||
* dialog 先 `await connectAndWait()`(開 WS 並等 open)**再** `startFlash()`(POST)。
|
||
* - visionA `useWebSocket(path, { enabled, onMessage, onOpen })` 是 **declarative**
|
||
* (靠 `enabled` false→true 觸發連線,無法回傳「已 open」的 promise)。
|
||
* 若 `setEnabled(true)` 後立刻 POST → race(POST 已送、WS 還沒 open,漏早期 progress)。
|
||
*
|
||
* **本 hook 的解法(選項 2,複用最多、無 race)**:
|
||
* 用 `useWebSocket` 的 `onOpen` callback 觸發 POST。呼叫端 `beginFlash(modelId)` 只設
|
||
* `enabled=true` + 記下 modelId;WS 真正 open 後才在 `onOpen` 內 `startFlash(serialNumber, modelId)`,
|
||
* 天然保證「先 open WS 再 POST」的順序(flash 很快就送 progress,順序錯會漏早期 percent)。
|
||
*
|
||
* **重連防重複 POST(`hasStartedRef`)**:
|
||
* `onOpen` 在**每次連線成功**(含斷線重連)都會觸發。若不防護,重連會重複 POST flash。
|
||
* 故用 `hasStartedRef` 記錄「本次 flash 已 POST 過」,重連時的 onOpen 不再 POST,只續收 progress。
|
||
*
|
||
* 認證:same-origin cookie(visiona_session HttpOnly)自動帶,**不放 token 到 URL**
|
||
* (對齊 use-websocket.ts 安全決策 + security 對 token-in-URL 的 Critical 否決)。
|
||
*/
|
||
|
||
"use client";
|
||
|
||
import { useCallback, useRef, useState } from "react";
|
||
|
||
import { useWebSocket } from "@/hooks/use-websocket";
|
||
import { type FlashProgress, useFlashStore } from "@/stores/flash-store";
|
||
|
||
interface UseFlashProgress {
|
||
/**
|
||
* 開始 flash:啟用 WS(`enabled=true`),WS open 後才 POST flash(順序保證)。
|
||
* @param modelId 要 flash 的模型 id
|
||
*/
|
||
beginFlash: (modelId: string) => void;
|
||
/** 停止:關閉 WS、重置狀態(不再重連、不再 POST)。 */
|
||
stop: () => void;
|
||
/** 目前是否已啟用 WS(供除錯 / 測試觀察)。 */
|
||
isActive: boolean;
|
||
}
|
||
|
||
/**
|
||
* @param serialNumber 目標裝置的硬體序號(kn_number)。
|
||
*
|
||
* ⚠️ ADR-018 serial 路由:WS path 與 flash POST 的識別值都必須用 serialNumber,
|
||
* 兩者要一致——local agent 端 flash 進度是 broadcast 到以「請求帶入的識別值」
|
||
* 為 key 的 room,POST 帶 serial、WS 帶 UUID 會收不到進度。
|
||
* serial 為空的裝置不可 beginFlash(呼叫端 FlashDialog disable)。
|
||
*/
|
||
export function useFlashProgress(serialNumber: string): UseFlashProgress {
|
||
const updateProgress = useFlashStore((s) => s.updateProgress);
|
||
const startFlash = useFlashStore((s) => s.startFlash);
|
||
|
||
// enabled 用 state 讓 useWebSocket 收到 false→true 變化 → 觸發連線。
|
||
const [enabled, setEnabled] = useState(false);
|
||
|
||
// 本次 flash 要送的 modelId(beginFlash 設定、onOpen 讀取)。
|
||
const modelIdRef = useRef<string | null>(null);
|
||
// 防重連重複 POST:本次 flash 已 POST 過就不再送。
|
||
const hasStartedRef = useRef(false);
|
||
|
||
const handleOpen = useCallback(() => {
|
||
// 只在「本次 flash 尚未 POST 過」時觸發 POST(重連時的 onOpen 會跳過)。
|
||
if (hasStartedRef.current) return;
|
||
const modelId = modelIdRef.current;
|
||
if (!modelId) return;
|
||
hasStartedRef.current = true;
|
||
void startFlash(serialNumber, modelId);
|
||
}, [serialNumber, startFlash]);
|
||
|
||
const handleMessage = useCallback(
|
||
(data: unknown) => {
|
||
const p = data as Partial<FlashProgress>;
|
||
// 防呆:非預期形狀(percent 非數字且無 error)直接忽略,不污染 store。
|
||
if (typeof p?.percent !== "number" && typeof p?.error !== "string") {
|
||
return;
|
||
}
|
||
updateProgress({
|
||
percent: typeof p.percent === "number" ? p.percent : 0,
|
||
stage: typeof p.stage === "string" ? p.stage : "",
|
||
message: typeof p.message === "string" ? p.message : undefined,
|
||
error: typeof p.error === "string" ? p.error : undefined,
|
||
});
|
||
},
|
||
[updateProgress],
|
||
);
|
||
|
||
useWebSocket(`/ws/devices/${encodeURIComponent(serialNumber)}/flash-progress`, {
|
||
enabled,
|
||
onOpen: handleOpen,
|
||
onMessage: handleMessage,
|
||
});
|
||
|
||
const beginFlash = useCallback((modelId: string) => {
|
||
modelIdRef.current = modelId;
|
||
hasStartedRef.current = false; // 新一輪 flash,重置「已 POST」旗標
|
||
setEnabled(true);
|
||
}, [setEnabled]);
|
||
|
||
const stop = useCallback(() => {
|
||
modelIdRef.current = null;
|
||
hasStartedRef.current = false;
|
||
setEnabled(false);
|
||
}, [setEnabled]);
|
||
|
||
return { beginFlash, stop, isActive: enabled };
|
||
}
|