fix(devices): 前端讀 tunnel_online 修雲端裝置恆離線

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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-04 06:46:44 +08:00
parent dbe5d6a78e
commit c0e2ac0a59
2 changed files with 107 additions and 1 deletions

View File

@ -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 () => { it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list不記錯誤", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse( jsonResponse(

View File

@ -77,6 +77,19 @@ export interface Device extends DeviceSummary {
* *
* - snake_case / camelCase * - snake_case / camelCase
* - `remoteStatus` `unknown` offline * - `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 { function normalizeDevice(raw: unknown): Device {
const r = (raw ?? {}) as Record<string, unknown>; const r = (raw ?? {}) as Record<string, unknown>;
@ -86,6 +99,11 @@ function normalizeDevice(raw: unknown): Device {
} }
return undefined; 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;
return { return {
id: String(pick<string>("id") ?? ""), id: String(pick<string>("id") ?? ""),
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""), name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
@ -93,7 +111,7 @@ function normalizeDevice(raw: unknown): Device {
type: String(pick<string>("type", "device_type") ?? ""), type: String(pick<string>("type", "device_type") ?? ""),
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected", status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
remoteStatus: remoteStatus:
(pick<string>("remote_status", "remoteStatus") as RemoteStatus) ?? "unknown", tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null, lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
firmwareVersion: firmwareVersion:
pick<string>("firmware_version", "firmwareVersion") ?? null, pick<string>("firmware_version", "firmwareVersion") ?? null,