From c0e2ac0a590080810398c22ea519a39a24d0a00b Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sat, 4 Jul 2026 06:46:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(devices):=20=E5=89=8D=E7=AB=AF=E8=AE=80=20t?= =?UTF-8?q?unnel=5Fonline=20=E4=BF=AE=E9=9B=B2=E7=AB=AF=E8=A3=9D=E7=BD=AE?= =?UTF-8?q?=E6=81=86=E9=9B=A2=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeDevice 原本只讀 remote_status(DB 靜態值、exchange 建 device 寫死 offline 永不更新),沒讀後端即時算出的 tunnel_online → tunnel 已通 但雲端裝置列表恆顯示離線。 改為:tunnel_online === true 時 remoteStatus 覆蓋為 online,否則 fallback 回原本 remote_status 判定,兩者皆缺維持 unknown。嚴格布林比對避免 truthy 誤判。多裝置誤判限制(後端 resolveTunnelStatus 寬鬆比對、正解 R3/Phase 1) 已在 code 註解標明,不在本次前端修法範圍。 新增 4 test(true→online / false→沿用 / 缺欄位→沿用 / 皆缺→unknown), 共 16 test 全綠、tsc/eslint 乾淨。Reviewer 0C/0M。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/stores/device-store.test.ts | 88 +++++++++++++++++++ visionA-frontend/src/stores/device-store.ts | 20 ++++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/visionA-frontend/src/stores/device-store.test.ts b/visionA-frontend/src/stores/device-store.test.ts index 4496b7d..0775e55 100644 --- a/visionA-frontend/src/stores/device-store.test.ts +++ b/visionA-frontend/src/stores/device-store.test.ts @@ -87,6 +87,94 @@ describe("useDeviceStore", () => { }); }); + it("tunnel_online === true → remoteStatus 覆蓋為 online(即使 remote_status 是 offline)", async () => { + // bug fix:後端 remote_status 是 DB 靜態 offline,但 tunnel_online 即時算出 tunnel 活著。 + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ + success: true, + data: [ + { + id: "dev-1", + name: "KL520", + type: "kl520", + status: "connected", + remote_status: "offline", // DB 靜態值 + tunnel_online: true, // 即時 tunnel 狀態 + }, + ], + }), + ); + + await useDeviceStore.getState().fetchDevices(); + expect(useDeviceStore.getState().devices[0]).toMatchObject({ + id: "dev-1", + remoteStatus: "online", + }); + }); + + it("tunnel_online === false → 沿用 remote_status 判定(不覆蓋)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ + success: true, + data: [ + { + id: "dev-1", + name: "KL520", + type: "kl520", + status: "disconnected", + remote_status: "reconnecting", + tunnel_online: false, + }, + ], + }), + ); + + await useDeviceStore.getState().fetchDevices(); + expect(useDeviceStore.getState().devices[0]).toMatchObject({ + id: "dev-1", + remoteStatus: "reconnecting", + }); + }); + + it("tunnel_online 缺欄位 → 沿用 remote_status(守住既有行為)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ + success: true, + data: [ + { + id: "dev-1", + name: "KL520", + type: "kl520", + status: "disconnected", + remote_status: "offline", + // 無 tunnel_online + }, + ], + }), + ); + + await useDeviceStore.getState().fetchDevices(); + expect(useDeviceStore.getState().devices[0]).toMatchObject({ + id: "dev-1", + remoteStatus: "offline", + }); + }); + + it("tunnel_online 與 remote_status 皆缺 → remoteStatus 預設 unknown", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ + success: true, + data: [{ id: "dev-1", name: "KL520", type: "kl520", status: "connected" }], + }), + ); + + await useDeviceStore.getState().fetchDevices(); + expect(useDeviceStore.getState().devices[0]).toMatchObject({ + id: "dev-1", + remoteStatus: "unknown", + }); + }); + it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list,不記錯誤", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( diff --git a/visionA-frontend/src/stores/device-store.ts b/visionA-frontend/src/stores/device-store.ts index 5d2c380..0c72132 100644 --- a/visionA-frontend/src/stores/device-store.ts +++ b/visionA-frontend/src/stores/device-store.ts @@ -77,6 +77,19 @@ export interface Device extends DeviceSummary { * 把後端回傳的裝置物件正規化成前端型別。 * - 接受 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; @@ -86,6 +99,11 @@ function normalizeDevice(raw: unknown): Device { } return undefined; }; + // tunnel_online 為 boolean(snake_case / camelCase 皆容),true 時覆蓋 remoteStatus。 + const tunnelOnline = pick("tunnel_online", "tunnelOnline"); + const rawRemoteStatus = pick("remote_status", "remoteStatus") as + | RemoteStatus + | undefined; return { id: String(pick("id") ?? ""), name: String(pick("name") ?? pick("device_name") ?? ""), @@ -93,7 +111,7 @@ function normalizeDevice(raw: unknown): Device { type: String(pick("type", "device_type") ?? ""), status: (pick("status") as DeviceHardwareStatus) ?? "disconnected", remoteStatus: - (pick("remote_status", "remoteStatus") as RemoteStatus) ?? "unknown", + tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"), lastSeenAt: pick("last_seen_at", "lastSeenAt") ?? null, firmwareVersion: pick("firmware_version", "firmwareVersion") ?? null,