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:
parent
dbe5d6a78e
commit
c0e2ac0a59
@ -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(
|
||||
|
||||
@ -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<string, unknown>;
|
||||
@ -86,6 +99,11 @@ function normalizeDevice(raw: unknown): Device {
|
||||
}
|
||||
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;
|
||||
return {
|
||||
id: String(pick<string>("id") ?? ""),
|
||||
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") ?? ""),
|
||||
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
|
||||
remoteStatus:
|
||||
(pick<string>("remote_status", "remoteStatus") as RemoteStatus) ?? "unknown",
|
||||
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
|
||||
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
||||
firmwareVersion:
|
||||
pick<string>("firmware_version", "firmwareVersion") ?? null,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user