問題:device 配對後停在 detected(rescan 偵測到硬體但沒 connect driver), 載入模型被 flash service IsConnected() 擋成 400。根因=前端漏了「連線」按鈕 (POC 有、移植時當 local-tool 專屬刪掉、詳情頁沒補回雲端版)。前端誤用 remoteStatus=online(tunnel) 當 flash 門檻,≠ device driver IsConnected()。 修法(方案 A、兩步式): - device-detail-client.tsx:連線/斷線按鈕 + 三分支 gate(未連線→連線鈕/ 連線中→loading disabled/已連線→斷線鈕+載入模型可用)+ driver status 呈現 - connect 走 serial(對齊 WP-C)、成功後 fetchDevice(UUID) 刷新(C4 混合模型, WS 推送因 backend 501 stub 不通故用輪詢) - 60s connect UX:loading hint「首次連線需載入韌體約1分鐘,請勿關閉頁面」 - FlashDialog gate 收緊 disabled !isOnline → !isOnline || !isDriverConnected - device-store connect 的 api.post 加 timeoutMs:130_000(全域 30s < connect 最壞 65s,單點覆寫;> local agent 120s ctx) - i18n devices.connect.* 13 key 兩語系 - TDD §10.5.1 過時型別修正 + 新增 §10.5.1.1 two-step connect 流程 推論線一併解決:能進 workspace 的裝置必已 connect(原無 IsConnected 檢查 會靜默空跑無框)。 Reviewer 通過(0C/0M/2Mi/3Sug、規格 12/12、gate 三分支全組合走查)。 tsc/eslint 0、vitest 44 passed、next build 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
/**
|
||
* Device Store — visionA Cloud
|
||
*
|
||
* 對齊:
|
||
* - `.autoflow/04-architecture/api/api-spec.md` §3 Devices(`/api/devices` / `/api/devices/:id` / connect / disconnect)
|
||
* - `.autoflow/03-design/flows/flow-offline-handling.md` §2(Device 新增 `remoteStatus`)
|
||
* - `.autoflow/04-architecture/TDD.md` §10.1(stores)
|
||
*
|
||
* 職責:
|
||
* - 保存當前使用者可見的裝置列表 + 當前選取裝置詳情
|
||
* - 接 `GET /api/devices` / `GET /api/devices/:id`(雛形後端透過 tunnel forward)
|
||
* - 提供 connect / disconnect 呼叫的 wrapper(純 API 呼叫;UI 實際狀態變更由 WS 推送)
|
||
*
|
||
* F6 範圍(雛形):
|
||
* - 不做主動 WS 訂閱 — 待 F7/F8 接 `/ws/devices/events`
|
||
* - 後端 501 NOT_IMPLEMENTED 時 fallback 為空 list,不打擾 UI
|
||
* - `remoteStatus` 欄位若後端未提供,默認 `unknown`(避免誤顯示為 offline)
|
||
*
|
||
* 雛形安全/穩定性備註:
|
||
* - 所有錯誤以 `error: string` 記錄,不 throw,讓 UI 能用 `{ isLoading, error }` 處理
|
||
* - unmount 時不主動 abort(雛形保持簡單;F7/F8 改用 TanStack Query 或 SWR 時再處理)
|
||
*/
|
||
|
||
"use client";
|
||
|
||
import { create } from "zustand";
|
||
|
||
import { ApiError, api } from "@/lib/api";
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Types — 對齊 api-spec.md §3 + flow-offline-handling.md §2 */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/** 既有 USB / 硬體層級狀態(由 local agent 回報) */
|
||
export type DeviceHardwareStatus =
|
||
| "detected"
|
||
| "connecting"
|
||
| "connected"
|
||
| "flashing"
|
||
| "inferencing"
|
||
| "error"
|
||
| "disconnected";
|
||
|
||
/** 遠端 tunnel 層級狀態(local agent ↔ 雲端) */
|
||
export type RemoteStatus = "online" | "offline" | "reconnecting" | "error" | "unknown";
|
||
|
||
/** 列表用的精簡裝置資訊 */
|
||
export interface DeviceSummary {
|
||
id: string;
|
||
name: string;
|
||
/** 使用者自訂別名(後端或本機偏好皆可) */
|
||
alias?: string;
|
||
/**
|
||
* Kneron 硬體序號(kn_number)— ADR-018 serial 路由的識別值。
|
||
* 後端 JSON key 為 `serial_number`(omitempty):舊資料 / 未串通 / 假序號
|
||
* `0x00000000` 皆為缺省 → null。null 時「路由到 local agent 的操作」
|
||
* (camera / media / inference WS / flash / connect / disconnect)必須 disable
|
||
* (FE-A 混合模型;DB 操作如列表 / 詳情 / unpair 仍用 UUID `id`)。
|
||
*/
|
||
serialNumber?: string | null;
|
||
type: string;
|
||
/** USB 層級狀態;離線時可能來自 cache */
|
||
status: DeviceHardwareStatus;
|
||
/** 遠端 tunnel 狀態(flow-offline-handling.md §2 新增) */
|
||
remoteStatus: RemoteStatus;
|
||
/** ISO 8601,最後心跳時間 */
|
||
lastSeenAt?: string | null;
|
||
firmwareVersion?: string | null;
|
||
flashedModel?: string | null;
|
||
}
|
||
|
||
/** 裝置詳情(詳細頁用,比 Summary 多欄) */
|
||
export interface Device extends DeviceSummary {
|
||
port?: string | null;
|
||
hostName?: string | null;
|
||
pairedAt?: string | null;
|
||
errorMessage?: string | null;
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* 後端 snake_case ⇄ 前端 camelCase 正規化 */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 把後端回傳的裝置物件正規化成前端型別。
|
||
* - 接受 snake_case / camelCase 兩種形狀
|
||
* - 對缺欄位寬容:`remoteStatus` 預設 `unknown`(而非 offline,避免誤判)
|
||
*
|
||
* remoteStatus 決策順序(bug fix:裝置恆離線):
|
||
* 1. 後端 `tunnel_online === true` → 覆蓋為 `online`
|
||
* 後端 `/api/devices` 即時查 tunnel session 算出 `tunnel_online`(devices.go),
|
||
* 是「當下 tunnel 是否活著」的真實狀態;而 `remote_status` 是 DB 靜態值,
|
||
* exchange 建立 device 時寫死 `offline` 之後永不更新,直接讀它會恆顯示離線。
|
||
* 2. 否則 fallback 回原本 `remote_status` / `remoteStatus`(守住既有行為)。
|
||
*
|
||
* ⚠️ 已知限制(技術債,只求單裝置 demo 正確):
|
||
* 後端 `tunnel_online` 由 `resolveTunnelStatus` 以寬鬆比對算出——session 未帶
|
||
* UserID/DeviceID 時(UserID=="")也會 match,故單裝置 demo 正確、多裝置場景會誤判
|
||
* (某台的 tunnel 活著可能讓另一台也判為 online)。多裝置的正解是後端 session
|
||
* backfill UserID/DeviceID(architect R3 / Phase 1),不在本次前端修法範圍內。
|
||
*/
|
||
function normalizeDevice(raw: unknown): Device {
|
||
const r = (raw ?? {}) as Record<string, unknown>;
|
||
const pick = <T = unknown>(...keys: string[]): T | undefined => {
|
||
for (const k of keys) {
|
||
if (r[k] !== undefined && r[k] !== null) return r[k] as T;
|
||
}
|
||
return undefined;
|
||
};
|
||
// tunnel_online 為 boolean(snake_case / camelCase 皆容),true 時覆蓋 remoteStatus。
|
||
const tunnelOnline = pick<boolean>("tunnel_online", "tunnelOnline");
|
||
const rawRemoteStatus = pick<string>("remote_status", "remoteStatus") as
|
||
| RemoteStatus
|
||
| undefined;
|
||
// serial_number 為 omitempty:缺欄位 → null;防禦性把空字串/純空白也視為 null
|
||
// (空字串與空白皆無法路由,語意上等同「未回報序號」)。先 trim 再判定,
|
||
// 避免純空白(" ")誤判為 truthy 而以空白 serial 組出必失敗的請求路由。
|
||
const rawSerial = pick<string>("serial_number", "serialNumber");
|
||
const serial = rawSerial != null ? String(rawSerial).trim() : "";
|
||
return {
|
||
id: String(pick<string>("id") ?? ""),
|
||
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
|
||
alias: pick<string>("alias") ?? undefined,
|
||
serialNumber: serial !== "" ? serial : null,
|
||
type: String(pick<string>("type", "device_type") ?? ""),
|
||
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
|
||
remoteStatus:
|
||
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
|
||
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
||
firmwareVersion:
|
||
pick<string>("firmware_version", "firmwareVersion") ?? null,
|
||
flashedModel: pick<string>("flashed_model", "flashedModel") ?? null,
|
||
port: pick<string>("port") ?? null,
|
||
hostName: pick<string>("host_name", "hostName") ?? null,
|
||
pairedAt: pick<string>("paired_at", "pairedAt") ?? null,
|
||
errorMessage: pick<string>("error_message", "errorMessage") ?? null,
|
||
};
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Store */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* unpair(移除裝置)action 的回傳:成功 / 失敗(帶 i18n code 給 UI 顯示 toast)。
|
||
*
|
||
* 與 model-store deleteModel 回傳 boolean 的差異:unpair 是破壞性操作,UI 需要對
|
||
* 「沒有權限(403)/ 裝置不存在(404)/ 其他」分流顯示不同文案,故回 code 而非 boolean。
|
||
* code 由 ApiError.code 直接帶出(backend api-spec §11 錯誤碼),UI 以 `devices.remove.error.*`
|
||
* 對應 i18n、找不到對應 key 時退化到 unknown 文案(對齊 model download 的 toast 慣例)。
|
||
*/
|
||
export type UnpairResult =
|
||
| { ok: true }
|
||
| { ok: false; code: string; message: string };
|
||
|
||
interface DeviceState {
|
||
devices: DeviceSummary[];
|
||
selectedDevice: Device | null;
|
||
isLoading: boolean;
|
||
/** 連線中的裝置識別值(serial 路由後為 serialNumber;UI 顯示 button spinner);不使用就是 null */
|
||
connectingId: string | null;
|
||
disconnectingId: string | null;
|
||
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
||
unpairingId: string | null;
|
||
error: string | null;
|
||
|
||
/** 呼叫 `GET /api/devices` */
|
||
fetchDevices: () => Promise<void>;
|
||
/** 呼叫 `GET /api/devices/:id` */
|
||
fetchDevice: (id: string) => Promise<void>;
|
||
/**
|
||
* 呼叫 `POST /api/devices/:serialNumber/connect`。
|
||
*
|
||
* ⚠️ ADR-018 serial 路由:connect 是純 proxy 操作(雲端零邏輯、透傳 local agent,
|
||
* 見 wp-c-connect-disconnect-routing-verification.md §1),path 識別值必須帶
|
||
* `device.serialNumber`(kn_number),不是雲端 UUID。serial 為空的裝置不可呼叫
|
||
* (呼叫端按鈕 disable)。
|
||
*/
|
||
connectDevice: (serialNumber: string) => Promise<boolean>;
|
||
/** 呼叫 `POST /api/devices/:serialNumber/disconnect`(serial 路由,同 connectDevice)。 */
|
||
disconnectDevice: (serialNumber: string) => Promise<boolean>;
|
||
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
||
unpairDevice: (id: string) => Promise<UnpairResult>;
|
||
/** 測試 / 雛形用:直接塞 list */
|
||
_setDevices: (devices: DeviceSummary[]) => void;
|
||
/** 測試 / 雛形用:直接塞 selected */
|
||
_setSelected: (device: Device | null) => void;
|
||
}
|
||
|
||
export const useDeviceStore = create<DeviceState>()((set) => ({
|
||
devices: [],
|
||
selectedDevice: null,
|
||
isLoading: false,
|
||
connectingId: null,
|
||
disconnectingId: null,
|
||
unpairingId: null,
|
||
error: null,
|
||
|
||
fetchDevices: async () => {
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
const raw = await api.get<unknown[]>("/api/devices");
|
||
const devices = Array.isArray(raw) ? raw.map(normalizeDevice) : [];
|
||
set({ devices, isLoading: false });
|
||
} catch (err) {
|
||
// 雛形後端 501 / 無 tunnel 時不當嚴重錯誤,給空 list
|
||
if (err instanceof ApiError && (err.code === "NOT_IMPLEMENTED" || err.code === "TUNNEL_DISCONNECTED")) {
|
||
set({ devices: [], isLoading: false, error: null });
|
||
return;
|
||
}
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
set({ isLoading: false, error: message });
|
||
}
|
||
},
|
||
|
||
fetchDevice: async (id) => {
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
const raw = await api.get<unknown>(`/api/devices/${encodeURIComponent(id)}`);
|
||
set({ selectedDevice: normalizeDevice(raw), isLoading: false });
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
|
||
// 雛形後端未實作 — UI 退回 list 上的最後一筆或顯示 "離線 cache"
|
||
set({ isLoading: false, error: null });
|
||
return;
|
||
}
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
set({ isLoading: false, error: message });
|
||
}
|
||
},
|
||
|
||
// serial 路由(ADR-018):path 帶 serialNumber(kn_number),local agent 端以
|
||
// serialToLocalID 反查 sessions;帶 UUID 會 "device not found"。
|
||
//
|
||
// ⚠️ timeout 覆寫(方案 A 自查項):connect 是唯一可能 > 60s 的請求
|
||
// (KL520 Loader-mode reconnect + firmware reload + reboot + 二次 reconnect,
|
||
// local agent 端最壞 ~65s、ctx 120s)。api client 全域預設 timeout 只有 30s
|
||
// (api.ts DEFAULT_TIMEOUT_MS),若不放寬會在後端還在跑時就前端 abort → 誤判失敗。
|
||
// 這裡覆寫為 130s(> local agent 120s ctx,確保由後端決定成敗、前端不搶先 timeout)。
|
||
connectDevice: async (serialNumber) => {
|
||
set({ connectingId: serialNumber, error: null });
|
||
try {
|
||
await api.post(
|
||
`/api/devices/${encodeURIComponent(serialNumber)}/connect`,
|
||
undefined,
|
||
{ timeoutMs: 130_000 },
|
||
);
|
||
set({ connectingId: null });
|
||
return true;
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
set({ connectingId: null, error: message });
|
||
return false;
|
||
}
|
||
},
|
||
|
||
disconnectDevice: async (serialNumber) => {
|
||
set({ disconnectingId: serialNumber, error: null });
|
||
try {
|
||
await api.post(`/api/devices/${encodeURIComponent(serialNumber)}/disconnect`);
|
||
set({ disconnectingId: null });
|
||
return true;
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
set({ disconnectingId: null, error: message });
|
||
return false;
|
||
}
|
||
},
|
||
|
||
unpairDevice: async (id) => {
|
||
set({ unpairingId: id, error: null });
|
||
try {
|
||
// backend 回 envelope { success, data: { id, unpaired: true } };api.post 已 unwrap data。
|
||
// 這裡不需用回傳值(成功與否由有無 throw 判定),故不取 response。
|
||
await api.post(`/api/devices/${encodeURIComponent(id)}/unpair`);
|
||
// 成功後從本地 list 移除該裝置(避免 refetch 延遲);若 selected 是它也清掉。
|
||
set((state) => ({
|
||
devices: state.devices.filter((d) => d.id !== id),
|
||
selectedDevice:
|
||
state.selectedDevice?.id === id ? null : state.selectedDevice,
|
||
unpairingId: null,
|
||
}));
|
||
return { ok: true };
|
||
} catch (err) {
|
||
// ApiError 帶 backend error code(FORBIDDEN / NOT_FOUND / …)給 UI 分流 toast;
|
||
// 其他例外(網路層)退化成 unknown。
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
const code = err instanceof ApiError ? err.code : "unknown";
|
||
set({ unpairingId: null, error: message });
|
||
return { ok: false, code, message };
|
||
}
|
||
},
|
||
|
||
_setDevices: (devices) => set({ devices }),
|
||
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
||
}));
|