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>
This commit is contained in:
jim800121chen 2026-07-16 15:30:33 +08:00
parent fac07c39d0
commit 1da385f345
6 changed files with 347 additions and 9 deletions

View File

@ -582,11 +582,24 @@ interface Device {
lastSeenAt?: string; // ISO 8601 lastSeenAt?: string; // ISO 8601
lastConnectedAt?: string; lastConnectedAt?: string;
// USB-levellocal agent 上報) // USB-level / driver-levellocal agent 上報)
status: 'online' | 'offline' | 'unknown'; // ⚠️ 型別更正2026-07-16舊版 TDD 誤寫為 'online'|'offline'|'unknown'
// 與實際 code 不符。實際型別為 DeviceHardwareStatusdevice-store.ts:34-42
// 以 code 為準:
status: DeviceHardwareStatus;
// ... // ...
} }
// DeviceHardwareStatusdevice-store.ts:34-42— driver 連線狀態的完整 enum
type DeviceHardwareStatus =
| 'detected' // USB 偵測到、driver 尚未 connect
| 'connecting' // driver 正在 connect重量級操作進行中
| 'connected' // driver 已 connect可 flash / 推論
| 'flashing' // 正在燒錄韌體(已 connect
| 'inferencing' // 正在推論(已 connect
| 'error' // driver 錯誤
| 'disconnected'; // driver 未連線
``` ```
前端顯示邏輯: 前端顯示邏輯:
@ -594,15 +607,44 @@ interface Device {
| 畫面位置 | 顯示依據 | | 畫面位置 | 顯示依據 |
|---------|---------| |---------|---------|
| Device 卡片主狀態 badge | **`remoteStatus`**(決定「雲端能不能操作它」)| | Device 卡片主狀態 badge | **`remoteStatus`**(決定「雲端能不能操作它」)|
| Device 卡片副狀態 | `status`USB 是否插著)— 僅在 `remoteStatus === 'online'` 時顯示有意義 | | Device 卡片副狀態 | `status`driver 連線狀態、七值 enum)— 僅在 `remoteStatus === 'online'` 時顯示有意義 |
| 「最後上線」時間 | `lastSeenAt`tunnel 最後心跳) | | 「最後上線」時間 | `lastSeenAt`tunnel 最後心跳) |
| Inference / Flash 按鈕啟用條件 | `remoteStatus === 'online' && status === 'online'` | | Inference / Flash 按鈕啟用條件 | `remoteStatus === 'online' && status ∈ {connected, flashing, inferencing}`driver 已 connect詳見 §10.5.1.1 兩步式 gate |
| 「重連中」loading | `remoteStatus === 'reconnecting'` | | 「重連中」loading | `remoteStatus === 'reconnecting'` |
| 「離線,請檢查 local-tool」提示 | `remoteStatus === 'offline'` | | 「離線,請檢查 local-tool」提示 | `remoteStatus === 'offline'` |
邏輯總結:前端永遠先看 `remoteStatus`(雲端是否可達),再看 `status`USB 是否接著)。兩者獨立更新,前端要訂閱兩種事件 邏輯總結:前端永遠先看 `remoteStatus`(雲端是否可達),再看 `status`driver 是否已 connect。兩者獨立更新
- `remoteStatus` 變化 → `WS /ws/devices/:id/remote-status`(來自 remote-proxy 轉發) - `remoteStatus` 變化 → `WS /ws/devices/:id/remote-status`(來自 remote-proxy 轉發)
- `status` 變化 → `WS /ws/devices/:id/status`(來自 local agent 的 USB 監聽事件) - `status` 變化 → 目前**輪詢式**connect / flash 完成後 `fetchDevice` 重拉。device events WS`/ws/devices/events`end-to-end 尚未打通backend 為 501 stub屬 B7故不依賴 WS 推 driver 狀態。詳見 §10.5.1.1。
#### 10.5.1.1 Driver-level connect兩步式操作方案 A
Kneron 裝置的 flash / 推論都要求 driver 先 `Connect()`(重量級操作,最壞 6065sKL520 reset + firmware reload + reconnect。雲端前端在 device 詳情頁提供獨立「連線 / 斷線」按鈕承載這一步(對齊 local-tool POC 的兩步式:先 connect device → 再載入模型 / 推論)。
**driver `status` vs `remoteStatus` 的區別(本次核心)**
| 欄位 | 語意 | 值 | 決定什麼 |
|------|------|-----|---------|
| `remoteStatus` | tunnel-level雲端能不能觸達 local agent | `online` / `offline` / `reconnecting` / `error` | local agent 在不在線 |
| `status`driver | driver-levelKneron driver 有沒有 `Connect()` | `DeviceHardwareStatus` 七值 | 能不能 flash / 推論 |
`remoteStatus === 'online'` 只代表「雲端可下指令給 local agent」能否 flash / 推論還要看 driver 是否 `connected``detected` = USB 偵測到但 driver 未 connect此時 flash 會被 local agent 擋成 400 `device not connected`)。這是兩步式 connect 的存在理由。
- **路由**connect / disconnect 走 **serial 路由**ADR-018 / WP-C`device-store.connectDevice(serial)` / `disconnectDevice(serial)`path `POST /api/devices/:serial/connect``:id` 段填 serial number / kn_number非雲端 UUID。雲端純 proxy、零邏輯。
- **gate 三分支**(前提 `remoteStatus === 'online' && serialNumber` 存在):
| driver `status` | 連線按鈕 | FlashDialog載入模型 | 開啟工作區 |
|------|------|------|------|
| `detected` / `disconnected` / `error` | 顯示「連線」primary | disabletooltip「請先連線裝置」 | disable |
| `connecting`(或本地 `connectingId` | loading「連線中…」、disable防重點 | disable | disable |
| `connected` / `flashing` / `inferencing` | 顯示「斷線」outline | enable | 沿用 `flashedModel` gate |
- **狀態刷新connect 60s 成本)**connect 為重量級操作,最壞 6065s。按下「連線」立即進 loading本地 `connectingId` 旗標,同步生效),並顯示 hint「首次連線需載入韌體約 1 分鐘請勿關閉頁面」承載等待體驗。connect 成功後**主動 `fetchDevice(uuid)` 重拉** `status`**輪詢式**),使 badge 由 `connecting``connected`、按鈕自動切「斷線」、FlashDialog 解鎖。**不依賴 WS 推送**——device events WS`/ws/devices/events`)雖 local agent 端已實作connect 成功廣播 `updated`),但 backend proxy 該路由仍是 **501 stubpending B7**事件到不了雲端前端。B7 打通後可改 WS 即時推。
- **timeout 鏈**backend proxy 300s`proxy.go` `defaultProxyRequestTimeout`> local agent 120s`device_handler.go` ConnectDevice> 實際最壞 65s層層都夠。前端無需自設 timeout唯一需自查`api.post` 全域 timeout 若 < 120s需為 connect 呼叫放寬connect 是唯一可能 > 60s 的請求)。
- **推論前置**:能進 workspace 的裝置必然已 connectflash 前置需 connect、開啟工作區前置需 `flashedModel` 有值 = 已 flash 過),故 camera pipeline 拿到的必為 connected driver**推論前端無需額外 connect gate**。camera_handler `IsConnected()` 早擋為 nice-to-have動 local agent、非本次範圍
### 10.6 local-tool-only 元件雛形隱藏Q6 裁決) ### 10.6 local-tool-only 元件雛形隱藏Q6 裁決)

View File

@ -217,3 +217,143 @@ describe("DeviceDetailClient serial 路由 gatingWP-C / ADR-018", () => {
expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled(); expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled();
}); });
}); });
describe("DeviceDetailClient 兩步式連線 gate方案 A / ADR-018", () => {
// 未連線裝置基礎(在線 + 有 serial但 driver status = detected
const detectedDevice: Device = {
...onlineDevice,
status: "detected",
};
it("未連線detected顯示「連線」按鈕、flash 觸發鈕 disabled", () => {
useDeviceStore.setState({ selectedDevice: detectedDevice });
renderDetail();
const connectBtn = screen.getByTestId("device-connect-btn");
expect(connectBtn).toBeEnabled();
expect(connectBtn).toHaveTextContent("連線");
// 兩步式driver 沒 connect → flash 入口 disable避免按了才收 400
expect(screen.getByTestId("flash-model-trigger")).toBeDisabled();
// 未連線不顯示斷線按鈕。
expect(screen.queryByTestId("device-disconnect-btn")).not.toBeInTheDocument();
// driver 狀態呈現。
expect(screen.getByTestId("device-driver-status")).toHaveTextContent("已偵測");
});
it("已連線connected顯示「斷線」按鈕、flash 觸發鈕 enabled", () => {
// onlineDevice.status === "connected"
renderDetail();
const disconnectBtn = screen.getByTestId("device-disconnect-btn");
expect(disconnectBtn).toBeEnabled();
expect(disconnectBtn).toHaveTextContent("斷線");
// 已連線 → flash 入口可用。
expect(screen.getByTestId("flash-model-trigger")).toBeEnabled();
expect(screen.queryByTestId("device-connect-btn")).not.toBeInTheDocument();
expect(screen.getByTestId("device-driver-status")).toHaveTextContent("已連線");
});
it("點「連線」→ 呼叫 connectDevice(serial) → fetchDevice(id) → success toast", async () => {
useDeviceStore.setState({ selectedDevice: detectedDevice });
const connectSpy = vi
.spyOn(useDeviceStore.getState(), "connectDevice")
.mockResolvedValue(true);
const fetchSpy = vi
.spyOn(useDeviceStore.getState(), "fetchDevice")
.mockResolvedValue(undefined);
renderDetail();
fireEvent.click(screen.getByTestId("device-connect-btn"));
// serial 路由connect 帶 serialNumber非 UUID
await waitFor(() =>
expect(connectSpy).toHaveBeenCalledWith("KN00123456"),
);
// C4connect 後用 UUID 重拉 status。
expect(fetchSpy).toHaveBeenCalledWith("dev-1");
expect(toast.success).toHaveBeenCalledOnce();
expect(toast.error).not.toHaveBeenCalled();
});
it("連線失敗 → toast.error帶 description不刷新成功文案", async () => {
useDeviceStore.setState({ selectedDevice: detectedDevice });
vi.spyOn(useDeviceStore.getState(), "connectDevice").mockResolvedValue(false);
vi.spyOn(useDeviceStore.getState(), "fetchDevice").mockResolvedValue(undefined);
renderDetail();
fireEvent.click(screen.getByTestId("device-connect-btn"));
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
const call = (toast.error as Mock).mock.calls[0];
expect(call[1].description).toBe("請確認裝置已插上且 local agent 在線");
expect(toast.success).not.toHaveBeenCalled();
});
it("連線中connectingId === serial連線按鈕 disabled + loading且顯示 60s hint", () => {
useDeviceStore.setState({
selectedDevice: detectedDevice,
connectingId: "KN00123456",
});
renderDetail();
const connectBtn = screen.getByTestId("device-connect-btn");
expect(connectBtn).toBeDisabled();
expect(connectBtn).toHaveTextContent("連線中…");
// 60s UX 明確 hint。
expect(screen.getByTestId("device-connecting-hint")).toHaveTextContent(
"約 1 分鐘",
);
});
it("後端上報 connecting無本地旗標也視為連線中loading + hint", () => {
useDeviceStore.setState({
selectedDevice: { ...detectedDevice, status: "connecting" },
connectingId: null,
});
renderDetail();
expect(screen.getByTestId("device-connect-btn")).toBeDisabled();
expect(screen.getByTestId("device-connecting-hint")).toBeInTheDocument();
expect(screen.getByTestId("device-driver-status")).toHaveTextContent("連線中");
});
it("點「斷線」→ 呼叫 disconnectDevice(serial) → fetchDevice(id) → toast", async () => {
const disconnectSpy = vi
.spyOn(useDeviceStore.getState(), "disconnectDevice")
.mockResolvedValue(true);
const fetchSpy = vi
.spyOn(useDeviceStore.getState(), "fetchDevice")
.mockResolvedValue(undefined);
renderDetail();
fireEvent.click(screen.getByTestId("device-disconnect-btn"));
await waitFor(() =>
expect(disconnectSpy).toHaveBeenCalledWith("KN00123456"),
);
expect(fetchSpy).toHaveBeenCalledWith("dev-1");
expect(toast.success).toHaveBeenCalledOnce();
});
it("離線remoteStatus=offline不顯示連線/斷線按鈕(操作區整體降級)", () => {
useDeviceStore.setState({
selectedDevice: { ...detectedDevice, remoteStatus: "offline" },
});
renderDetail();
expect(screen.queryByTestId("device-connect-btn")).not.toBeInTheDocument();
expect(
screen.queryByTestId("device-disconnect-btn"),
).not.toBeInTheDocument();
});
it("flashing / inferencing 也視為已連線(斷線按鈕 + flash 可用)", () => {
useDeviceStore.setState({
selectedDevice: { ...onlineDevice, status: "inferencing" },
});
renderDetail();
expect(screen.getByTestId("device-disconnect-btn")).toBeInTheDocument();
expect(screen.getByTestId("flash-model-trigger")).toBeEnabled();
});
});

View File

@ -65,10 +65,46 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
const unpairDevice = useDeviceStore((s) => s.unpairDevice); const unpairDevice = useDeviceStore((s) => s.unpairDevice);
const isUnpairing = useDeviceStore((s) => s.unpairingId === id); const isUnpairing = useDeviceStore((s) => s.unpairingId === id);
// 兩步式連線(方案 A / ADR-018 serial 路由connect/disconnect action + 進行中旗標。
const connectDevice = useDeviceStore((s) => s.connectDevice);
const disconnectDevice = useDeviceStore((s) => s.disconnectDevice);
// connectingId / disconnectingId 存的是 serialNumberserial 路由);用當前裝置 serial 比對。
const serial = selectedDevice?.serialNumber ?? "";
const isConnectPending = useDeviceStore(
(s) => !!serial && s.connectingId === serial,
);
const isDisconnectPending = useDeviceStore(
(s) => !!serial && s.disconnectingId === serial,
);
useEffect(() => { useEffect(() => {
if (id) void fetchDevice(id); if (id) void fetchDevice(id);
}, [id, fetchDevice]); }, [id, fetchDevice]);
async function handleConnect() {
// gate 已保證 hasSerialserialNumber 必有值。
const ok = await connectDevice(selectedDevice!.serialNumber!);
if (ok) {
// C4connect 後唯一可靠刷新路徑 — 用 UUID 重拉 statusWS 推送未通)。
await fetchDevice(id);
toast.success(t("devices.connect.toast.success"));
} else {
toast.error(t("devices.connect.toast.failed"), {
description: t("devices.connect.toast.failedDesc"),
});
}
}
async function handleDisconnect() {
const ok = await disconnectDevice(selectedDevice!.serialNumber!);
if (ok) {
await fetchDevice(id);
toast.success(t("devices.connect.toast.disconnected"));
} else {
toast.error(t("devices.connect.toast.disconnectFailed"));
}
}
async function handleRemove() { async function handleRemove() {
const result = await unpairDevice(id); const result = await unpairDevice(id);
if (result.ok) { if (result.ok) {
@ -125,6 +161,18 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
// 詳情頁本身fetchDevice / unpair維持 UUID不受 serial 影響。 // 詳情頁本身fetchDevice / unpair維持 UUID不受 serial 影響。
const hasSerial = !!selectedDevice.serialNumber; const hasSerial = !!selectedDevice.serialNumber;
// driverUSB連線狀態 — 方案 A 兩步式的第一步 gateC1status 已 normalize
const usbStatus = selectedDevice.status;
// connected / flashing / inferencing 三態都代表 driver 已 Connect()、可載入模型 / 推論。
const isDriverConnected =
usbStatus === "connected" ||
usbStatus === "flashing" ||
usbStatus === "inferencing";
// 後端上報的 connecting僅在有 fetchDevice 拿到時才有);與本地 isConnectPending 併判。
const isConnecting = usbStatus === "connecting";
// loading 判定:以本地旗標為主(一定會亮),後端 connecting 補「別分頁觸發」場景。
const isConnectInProgress = isConnectPending || isConnecting;
return ( return (
<div className="mx-auto max-w-5xl space-y-6 px-6 py-8"> <div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
<Link href="/devices"> <Link href="/devices">
@ -168,12 +216,55 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* flash flash disable {/* 連線 / driver-level connect serial
isOnline && hasSerialtunnel
loading
connect flash FlashDialog */}
{isOnline && hasSerial &&
(isDriverConnected ? (
<Button
variant="outline"
onClick={handleDisconnect}
disabled={isDisconnectPending}
data-testid="device-disconnect-btn"
>
{isDisconnectPending ? (
<>
<Spinner size="sm" label={t("devices.connect.disconnecting")} />
{t("devices.connect.disconnecting")}
</>
) : (
t("devices.connect.disconnect")
)}
</Button>
) : (
<Button
onClick={handleConnect}
disabled={isConnectInProgress}
data-testid="device-connect-btn"
>
{isConnectInProgress ? (
<>
<Spinner size="sm" label={t("devices.connect.connecting")} />
{t("devices.connect.connecting")}
</>
) : (
t("devices.connect.action")
)}
</Button>
))}
{/* flash + driver flash disable
driver connect flash local agent 400device not connected
disable 使 400 bug
WP-CADR-018flash serial serial disable + tooltip WP-CADR-018flash serial serial disable + tooltip
flash dialog fetchDevice flashedModel flash dialog fetchDevice flashedModel
gate gate */} gate gate */}
{hasSerial ? ( {hasSerial ? (
<FlashDialog deviceId={selectedDevice.id} disabled={!isOnline} /> <FlashDialog
deviceId={selectedDevice.id}
disabled={!isOnline || !isDriverConnected}
/>
) : ( ) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -271,6 +362,17 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
</div> </div>
</div> </div>
{/* 60s connect UX loading hint
使 1 */}
{isOnline && hasSerial && isConnectInProgress && (
<p
className="text-muted-foreground text-xs"
data-testid="device-connecting-hint"
>
{t("devices.connect.connectingHint")}
</p>
)}
<Separator /> <Separator />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@ -297,6 +399,17 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
) )
} }
/> />
{/* driverUSB連線狀態呈現C5 / A使 device
detected / connecting / connected gate
devices.status.* key */}
<InfoRow
label={t("devices.connect.driverStatusLabel")}
value={
<span data-testid="device-driver-status">
{t(`devices.status.${usbStatus}`)}
</span>
}
/>
<InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} /> <InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} />
<InfoRow <InfoRow
label={t("devices.detail.firmware")} label={t("devices.detail.firmware")}

View File

@ -196,6 +196,23 @@ export const en: Dictionary = {
"devices.flash.flashComplete": "Model loaded successfully", "devices.flash.flashComplete": "Model loaded successfully",
"devices.flash.flashFailed": "Failed to load model", "devices.flash.flashFailed": "Failed to load model",
// ── Devices: connect (driver-level connect, ADR-018 serial routing / two-step flow A) ──
"devices.connect.action": "Connect",
"devices.connect.connecting": "Connecting…",
"devices.connect.connectingHint":
"First connect loads firmware (~1 min). Please keep this page open.",
"devices.connect.disconnect": "Disconnect",
"devices.connect.disconnecting": "Disconnecting…",
"devices.connect.needConnectHint": "Connect the device first to flash a model",
"devices.connect.driverStatusLabel": "Connection",
"devices.connect.toast.success": "Device connected",
"devices.connect.toast.failed": "Connect failed",
"devices.connect.toast.failedDesc":
"Make sure the device is plugged in and the local agent is online.",
"devices.connect.toast.timeoutDesc": "Connect timed out. Please try again.",
"devices.connect.toast.disconnected": "Device disconnected",
"devices.connect.toast.disconnectFailed": "Disconnect failed",
// ── Remote Device Badge ── // ── Remote Device Badge ──
"remote.status.online": "Online", "remote.status.online": "Online",
"remote.status.offline": "Offline", "remote.status.offline": "Offline",

View File

@ -197,6 +197,22 @@ export const zhHant: Dictionary = {
"devices.flash.flashComplete": "模型載入成功", "devices.flash.flashComplete": "模型載入成功",
"devices.flash.flashFailed": "模型載入失敗", "devices.flash.flashFailed": "模型載入失敗",
// ── Devices: connectdriver-level 連線ADR-018 serial 路由 / 方案 A 兩步式) ──
"devices.connect.action": "連線",
"devices.connect.connecting": "連線中…",
"devices.connect.connectingHint":
"首次連線需載入韌體,約 1 分鐘,請勿關閉頁面",
"devices.connect.disconnect": "斷線",
"devices.connect.disconnecting": "斷線中…",
"devices.connect.needConnectHint": "請先連線裝置才能載入模型",
"devices.connect.driverStatusLabel": "連線狀態",
"devices.connect.toast.success": "裝置已連線",
"devices.connect.toast.failed": "連線失敗",
"devices.connect.toast.failedDesc": "請確認裝置已插上且 local agent 在線",
"devices.connect.toast.timeoutDesc": "連線逾時,請重試",
"devices.connect.toast.disconnected": "裝置已斷線",
"devices.connect.toast.disconnectFailed": "斷線失敗",
// ── Remote Device Badge雲端 tunnel 狀態) ── // ── Remote Device Badge雲端 tunnel 狀態) ──
"remote.status.online": "在線", "remote.status.online": "在線",
"remote.status.offline": "離線", "remote.status.offline": "離線",

View File

@ -231,10 +231,20 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
// serial 路由ADR-018path 帶 serialNumberkn_numberlocal agent 端以 // serial 路由ADR-018path 帶 serialNumberkn_numberlocal agent 端以
// serialToLocalID 反查 sessions帶 UUID 會 "device not found"。 // 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) => { connectDevice: async (serialNumber) => {
set({ connectingId: serialNumber, error: null }); set({ connectingId: serialNumber, error: null });
try { try {
await api.post(`/api/devices/${encodeURIComponent(serialNumber)}/connect`); await api.post(
`/api/devices/${encodeURIComponent(serialNumber)}/connect`,
undefined,
{ timeoutMs: 130_000 },
);
set({ connectingId: null }); set({ connectingId: null });
return true; return true;
} catch (err) { } catch (err) {