jim800121chen 1da385f345 feat(frontend): 裝置詳情頁連線/斷線按鈕 + 兩步式 gate(解載入模型 device not connected)
問題: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>
2026-07-16 15:30:33 +08:00

297 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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` §2Device 新增 `remoteStatus`
* - `.autoflow/04-architecture/TDD.md` §10.1stores
*
* 職責:
* - 保存當前使用者可見的裝置列表 + 當前選取裝置詳情
* - 接 `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/DeviceIDarchitect 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 為 booleansnake_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 路由後為 serialNumberUI 顯示 button spinner不使用就是 null */
connectingId: string | null;
disconnectingId: string | null;
/** 移除unpair中的裝置 idUI 顯示 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 §1path 識別值必須帶
* `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-018path 帶 serialNumberkn_numberlocal 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 codeFORBIDDEN / 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 }),
}));