From 67737334c895679aa21b5a6346b053b9e87033f2 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Fri, 17 Jul 2026 09:25:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(device):=20GET=20/api/devices/:id=20proxy?= =?UTF-8?q?=20=E6=8B=BF=E5=8D=B3=E6=99=82=20driver=20status=EF=BC=88?= =?UTF-8?q?=E6=96=B9=E6=A1=88=20Y-2=EF=BC=8C=E8=A7=A3=E8=BC=89=E5=85=A5?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20disabled=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題:連線後裝置詳情頁連線狀態顯示 unknown、載入模型按鈕永遠 disabled。 根因=雲端 GET /api/devices/:id 是純 DB 讀、沒 proxy 到 local agent → DB 只有 tunnel 層 status(online/offline/unknown)、沒有 driver 七態(detected/ connected/...)→ 前端 gate isDriverConnected 永遠 false。(上輪 C1/C5 查證 假設 status 拿得到、沒追到寫入點的漏洞) 修法(方案 Y-2、local agent 零改、gate 零改): - backend device_driver_status.go(新):driverStatusFetcher 介面 + forwarderDriverStatusFetcher(走既有 session.Forwarder proxy)+ envelope 解析 - devices.go devicesGetHandler:讀 DB metadata 後,device 有序號時額外 proxy 打 local agent GET /api/devices/{serial}(serial 路由對齊 WP-C)拿即時 driver status 覆蓋 USBStatus;remoteStatus/tunnel_online 保留(offline banner 不壞) - graceful fallback(全回 200 不掛 500):無序號/tunnel 離線/不可達/timeout/ 非2xx/success:false/空status/Forwarder未配置 → 保留 DB status + Debug log - 2s 短 timeout(不拖詳情頁)、serial path url.PathEscape 防禦 - frontend:DeviceHardwareStatus 加 unknown + coerceHardwareStatus(非七值→ unknown)+ normalizeDevice fallback disconnected→unknown(修誤顯未連接)+ i18n devices.status.unknown 兩語系(未確認/Unknown) Reviewer 通過(0C/0M/3Mi/2Sug、Y-2 10/10、端到端追證 gate 放行 + 8 fallback 分支無一掛 500)。backend 8 測試 + frontend 49 passed、build/vet/test 綠、 gitleaks 0。端到端「即時 connected 覆蓋 unknown」需在線 agent+登入實測(單元 測試已覆蓋合併+fallback)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- visionA-backend/internal/api/api.go | 6 + .../internal/api/device_driver_status.go | 151 +++++++++++++ .../internal/api/device_driver_status_test.go | 204 ++++++++++++++++++ visionA-backend/internal/api/devices.go | 32 ++- .../[id]/device-detail-client.test.tsx | 12 ++ .../src/lib/i18n/dictionaries/en.ts | 1 + .../src/lib/i18n/dictionaries/zh-Hant.ts | 1 + .../src/stores/device-store.test.ts | 22 ++ visionA-frontend/src/stores/device-store.ts | 30 ++- 9 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 visionA-backend/internal/api/device_driver_status.go create mode 100644 visionA-backend/internal/api/device_driver_status_test.go diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go index 6ec8a2d..f96d6c9 100644 --- a/visionA-backend/internal/api/api.go +++ b/visionA-backend/internal/api/api.go @@ -86,6 +86,12 @@ type Deps struct { SessionStore session.Store Forwarder *session.Forwarder + // DriverStatusFetcher 是 GET /api/devices/:id「合併 local agent 即時 driver status」 + // (方案 Y-2)的可選注入點。為 nil 時 handler 從 Forwarder + SessionStore 組 default + //(forwarderDriverStatusFetcher,走既有 tunnel proxy)。unit test 注入 stub 驗合併 / + // fallback,不需真 tunnel。詳見 device_driver_status.go。 + DriverStatusFetcher driverStatusFetcher + DeviceRepo device.Repository ModelRepo model.Repository diff --git a/visionA-backend/internal/api/device_driver_status.go b/visionA-backend/internal/api/device_driver_status.go new file mode 100644 index 0000000..d943967 --- /dev/null +++ b/visionA-backend/internal/api/device_driver_status.go @@ -0,0 +1,151 @@ +// device_driver_status.go — GET /api/devices/:id 的「即時 driver status」合併邏輯(方案 Y-2)。 +// +// 背景(driver-status-source-gap-diagnosis.md 方案 Y-2): +// 雲端 DB 只有 tunnel-level 的 RemoteStatus 與靜態 USBStatus(online/offline/unknown), +// 沒有 local agent 的 driver 七態(detected/connected/flashing/...)。前端 gate +// isDriverConnected 用 selectedDevice.status 判斷「driver 是否連上」,資料源必須是 +// local agent 的即時值,否則永遠 unknown → 載入模型按鈕恆 disabled。 +// +// 本檔負責:讀完 DB metadata 後,額外 proxy 一次 local agent GET /api/devices/:serial +// (走 serial 路由、對齊 ADR-018 / WP-C),把即時 driver status 覆蓋到回應的 status 欄。 +// +// 關鍵設計:**graceful fallback**。proxy 失敗 / timeout / tunnel 離線 / device 無序號時, +// 一律退回 DB 的靜態 status,GET :id 照常回 200——driver status 是加值,拿不到不能讓 +// 詳情頁整條掛掉。timeout 刻意設短(driverStatusProxyTimeout),避免詳情頁載入被拖慢。 +// +// 可測性:把「打 local agent 拿即時 status」抽成 driverStatusFetcher 介面,default 實作 +// 包 session.Forwarder(走既有 proxy 基礎設施);unit test 注入 stub 驗合併 / 各種 fallback, +// 不需要真 tunnel。 + +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "time" + + "visiona-backend/internal/session" +) + +// driverStatusProxyTimeout 是「額外打 local agent 拿即時 driver status」的整體 timeout。 +// +// 刻意設短(2s):driver status 是詳情頁的加值資訊,拿不到就 fallback DB status。 +// 不能讓 local agent hang 住時把整個詳情頁載入拖到跟 defaultProxyRequestTimeout(300s) 一樣久。 +const driverStatusProxyTimeout = 2 * time.Second + +// driverStatusFetcher 抽象「向 local agent 查某序號的即時 driver status」。 +// +// 回傳的 string 是 local agent DeviceInfo.Status(driver 七態,如 "connected")。 +// error 非 nil 代表拿不到(tunnel 離線 / local agent 不可達 / 該序號無 session / timeout / +// 非 2xx 回應 / 解析失敗)—— caller 必須 graceful fallback 到 DB status,不得 raise。 +// +// default 實作 forwarderDriverStatusFetcher 走既有 session.Forwarder proxy 基礎設施; +// unit test 注入 stub 驗合併與 fallback 分支。 +type driverStatusFetcher interface { + // FetchDriverStatus 打 local agent GET /api/devices/{serial} 拿即時 driver status。 + // userID 用來挑當前 user 的 active session token(與其他 proxy 端點同一套 posture)。 + FetchDriverStatus(ctx context.Context, userID, serial string) (string, error) +} + +// localAgentEnvelope 是 local agent GET /api/devices/:id 的回應 envelope。 +// +// 對齊 local-agent device_handler.go GetDevice:{"success":true,"data": DeviceInfo{...}}。 +// 只解出我們要的 status 欄(DeviceInfo.Status,camelCase JSON tag 為 "status")。 +type localAgentEnvelope struct { + Success bool `json:"success"` + Data struct { + Status string `json:"status"` + } `json:"data"` +} + +// forwarderDriverStatusFetcher 是 driverStatusFetcher 的 production 實作: +// 透過 session.Forwarder 把 GET /api/devices/{serial} 經 tunnel 送到 local agent。 +type forwarderDriverStatusFetcher struct { + forwarder *session.Forwarder + sessionStore session.Store +} + +// newForwarderDriverStatusFetcher 從 Deps 組出 default fetcher。 +// forwarder / sessionStore 任一為 nil 時回 nil(caller 據此略過即時查詢、只回 DB status)。 +func newForwarderDriverStatusFetcher(deps Deps) driverStatusFetcher { + if deps.Forwarder == nil || deps.SessionStore == nil { + return nil + } + return &forwarderDriverStatusFetcher{ + forwarder: deps.Forwarder, + sessionStore: deps.SessionStore, + } +} + +// FetchDriverStatus 實作 driverStatusFetcher。 +// +// 流程(對齊 newProxyHandler,但目標 path 固定為 local agent 的 GET /api/devices/{serial}): +// 1. 挑當前 user 的 active session token(pickActiveSessionToken) +// 2. 組 GET /api/devices/{serial} request,經 Forwarder.ForwardHTTP 送到 local agent +// 3. 解 envelope 取 data.status +// +// 任一步失敗都回 error(含非 2xx、success:false、空 status)——caller 一律 fallback DB。 +func (f *forwarderDriverStatusFetcher) FetchDriverStatus(ctx context.Context, userID, serial string) (string, error) { + // 短 timeout:driver status 是加值,別拖慢詳情頁。 + ctx, cancel := context.WithTimeout(ctx, driverStatusProxyTimeout) + defer cancel() + + token, err := pickActiveSessionToken(ctx, f.sessionStore, userID, nil) + if err != nil { + // tunnel 離線 / 無 active session → 拿不到即時 status,交由 caller fallback。 + return "", err + } + + // 走 serial 路由(ADR-018 / WP-C):local agent GetDevice 支援序號當 :id。 + // path 用 url.PathEscape 保護序號(雖然序號目前是 0x... 十六進位、無特殊字元,仍防禦性處理)。 + outReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "/api/devices/"+url.PathEscape(serial), nil) + if err != nil { + return "", err + } + + resp, err := f.forwarder.ForwardHTTP(ctx, token, outReq) + if err != nil { + // local agent 不可達 / dial 失敗 / timeout。 + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // local agent 回 404(該序號無 session)等 → 視為拿不到即時 status。 + // drain 一小段 body 讓 conn 能重用(best-effort,錯誤忽略)。 + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + return "", errDriverStatusUnavailable + } + + var env localAgentEnvelope + if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&env); err != nil { + return "", err + } + if !env.Success || env.Data.Status == "" { + return "", errDriverStatusUnavailable + } + return env.Data.Status, nil +} + +// errDriverStatusUnavailable 表示 local agent 回應存在但沒帶可用的即時 driver status +// (非 2xx / success:false / 空 status)。與「tunnel 離線」等傳輸層錯誤語意區隔, +// 方便 caller 記 log 時分辨,但兩者都同樣 fallback DB status。 +var errDriverStatusUnavailable = &driverStatusError{"driver status unavailable from local agent"} + +type driverStatusError struct{ msg string } + +func (e *driverStatusError) Error() string { return e.msg } + +// resolveDriverStatusFetcher 決定要用哪個 fetcher: +// - Deps.DriverStatusFetcher 非 nil(測試注入 stub)→ 用它 +// - 否則從 Forwarder + SessionStore 組 default(production) +// - 兩者皆缺 → 回 nil(handler 略過即時查詢、只回 DB status) +func resolveDriverStatusFetcher(deps Deps) driverStatusFetcher { + if deps.DriverStatusFetcher != nil { + return deps.DriverStatusFetcher + } + return newForwarderDriverStatusFetcher(deps) +} diff --git a/visionA-backend/internal/api/device_driver_status_test.go b/visionA-backend/internal/api/device_driver_status_test.go new file mode 100644 index 0000000..38d0ebc --- /dev/null +++ b/visionA-backend/internal/api/device_driver_status_test.go @@ -0,0 +1,204 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/device" + "visiona-backend/internal/session" +) + +// stubDriverStatusFetcher 是 driverStatusFetcher 的測試替身。 +// +// 可設定:回傳的即時 status(liveStatus)、回傳的 error(err),並記錄呼叫參數, +// 用來驗證 handler 是否有嘗試查詢、以及傳的序號 / userID 正確。 +type stubDriverStatusFetcher struct { + liveStatus string + err error + + called bool + gotUserID string + gotSerial string +} + +func (s *stubDriverStatusFetcher) FetchDriverStatus(_ context.Context, userID, serial string) (string, error) { + s.called = true + s.gotUserID = userID + s.gotSerial = serial + return s.liveStatus, s.err +} + +// getDeviceStatus 打 GET /api/devices/:id 並解出回應的 data.status 欄。 +func getDeviceStatus(t *testing.T, r *gin.Engine, id string) (int, string) { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/"+id, nil)) + + var sb SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb), "body=%s", w.Body.String()) + if w.Code != http.StatusOK { + return w.Code, "" + } + data, ok := sb.Data.(map[string]any) + require.True(t, ok, "data 應為物件,實際 body=%s", w.Body.String()) + status, _ := data["status"].(string) + return w.Code, status +} + +// newGetDeviceFixture 建 router + 塞一顆 device,可注入自訂 Deps 欄位(fetcher)。 +func newGetDeviceFixture(t *testing.T, d *device.Device, mutate func(*Deps)) *gin.Engine { + t.Helper() + repo := device.NewInMemoryRepository() + require.NoError(t, repo.Save(context.Background(), d)) + + r := gin.New() + r.Use(RequestIDMiddleware()) + r.Use(injectStaticUserContext(d.OwnerUserID, "")) + g := r.Group("/api") + + deps := Deps{ + DeviceRepo: repo, + SessionStore: &fakeSessionStore{}, + } + if mutate != nil { + mutate(&deps) + } + registerDeviceRoutes(g, deps) + return r +} + +// TestGetDevice_ProxySuccess_OverridesDBStatus 驗證:proxy 成功拿到即時 driver status +// ("connected")時,回應的 status 用即時值覆蓋 DB 的靜態值("unknown")。 +// +// 這是 gate isDriverConnected 能通過的核心路徑(DB unknown → 即時 connected)。 +func TestGetDevice_ProxySuccess_OverridesDBStatus(t *testing.T) { + fetcher := &stubDriverStatusFetcher{liveStatus: "connected"} + r := newGetDeviceFixture(t, &device.Device{ + ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520", + SerialNumber: "0xB906162C", + RemoteStatus: device.RemoteStatusOnline, + Status: device.USBStatusUnknown, // DB 靜態值 + CreatedAt: time.Now().UTC(), + }, func(d *Deps) { d.DriverStatusFetcher = fetcher }) + + code, status := getDeviceStatus(t, r, "dev1") + require.Equal(t, http.StatusOK, code) + assert.Equal(t, "connected", status, "應以即時 driver status 覆蓋 DB unknown") + + assert.True(t, fetcher.called, "應嘗試打 local agent") + assert.Equal(t, "0xB906162C", fetcher.gotSerial, "應以序號走 serial 路由") + assert.Equal(t, "demo-user", fetcher.gotUserID, "應帶當前 user") +} + +// TestGetDevice_ProxyFails_FallbackDBStatus 驗證:proxy 回 error(local agent 不可達 / +// 該序號無 session)時 graceful fallback——回 DB 的 status,且 GET :id 仍是 200。 +func TestGetDevice_ProxyFails_FallbackDBStatus(t *testing.T) { + fetcher := &stubDriverStatusFetcher{err: errDriverStatusUnavailable} + r := newGetDeviceFixture(t, &device.Device{ + ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520", + SerialNumber: "0xB906162C", + RemoteStatus: device.RemoteStatusOnline, + Status: device.USBStatusOnline, // DB 靜態值 + CreatedAt: time.Now().UTC(), + }, func(d *Deps) { d.DriverStatusFetcher = fetcher }) + + code, status := getDeviceStatus(t, r, "dev1") + require.Equal(t, http.StatusOK, code, "proxy 失敗不能讓詳情頁掛") + assert.Equal(t, device.USBStatusOnline, status, "應 fallback 回 DB status") + assert.True(t, fetcher.called) +} + +// TestGetDevice_TunnelDisconnected_FallbackDBStatus 驗證:tunnel 離線 +// (fetcher 回 session.ErrSessionNotFound)時 fallback DB status、仍回 200。 +func TestGetDevice_TunnelDisconnected_FallbackDBStatus(t *testing.T) { + fetcher := &stubDriverStatusFetcher{err: session.ErrSessionNotFound} + r := newGetDeviceFixture(t, &device.Device{ + ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520", + SerialNumber: "0xB906162C", + RemoteStatus: device.RemoteStatusOffline, + Status: device.USBStatusUnknown, + CreatedAt: time.Now().UTC(), + }, func(d *Deps) { d.DriverStatusFetcher = fetcher }) + + code, status := getDeviceStatus(t, r, "dev1") + require.Equal(t, http.StatusOK, code) + assert.Equal(t, device.USBStatusUnknown, status, "tunnel 離線應 fallback DB unknown") + assert.True(t, errors.Is(fetcher.err, session.ErrSessionNotFound)) +} + +// TestGetDevice_EmptyLiveStatus_FallbackDBStatus 驗證:proxy 成功但回空 status +// (不該覆蓋成空字串)時,保留 DB status。 +func TestGetDevice_EmptyLiveStatus_FallbackDBStatus(t *testing.T) { + fetcher := &stubDriverStatusFetcher{liveStatus: ""} + r := newGetDeviceFixture(t, &device.Device{ + ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520", + SerialNumber: "0xB906162C", + RemoteStatus: device.RemoteStatusOnline, + Status: device.USBStatusOnline, + CreatedAt: time.Now().UTC(), + }, func(d *Deps) { d.DriverStatusFetcher = fetcher }) + + code, status := getDeviceStatus(t, r, "dev1") + require.Equal(t, http.StatusOK, code) + assert.Equal(t, device.USBStatusOnline, status, "空即時 status 不該覆蓋 DB status") +} + +// TestGetDevice_NoSerial_SkipsProxy 驗證:device 無序號時完全不打 local agent +// (無序號不支援 serial 路由),直接回 DB status。 +func TestGetDevice_NoSerial_SkipsProxy(t *testing.T) { + fetcher := &stubDriverStatusFetcher{liveStatus: "connected"} + r := newGetDeviceFixture(t, &device.Device{ + ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520", + SerialNumber: "", // 無序號 + RemoteStatus: device.RemoteStatusOnline, + Status: device.USBStatusUnknown, + CreatedAt: time.Now().UTC(), + }, func(d *Deps) { d.DriverStatusFetcher = fetcher }) + + code, status := getDeviceStatus(t, r, "dev1") + require.Equal(t, http.StatusOK, code) + assert.Equal(t, device.USBStatusUnknown, status, "無序號回 DB status") + assert.False(t, fetcher.called, "無序號不該打 local agent") +} + +// TestGetDevice_NoFetcher_ReturnsDBStatus 驗證:沒有 fetcher(Forwarder/SessionStore +// 未配置,resolveDriverStatusFetcher 回 nil)時 handler 不 panic,直接回 DB status。 +func TestGetDevice_NoFetcher_ReturnsDBStatus(t *testing.T) { + r := newGetDeviceFixture(t, &device.Device{ + ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520", + SerialNumber: "0xB906162C", + RemoteStatus: device.RemoteStatusOnline, + Status: device.USBStatusOnline, + CreatedAt: time.Now().UTC(), + }, nil) // 不注入 fetcher;fakeSessionStore 存在但 Forwarder 為 nil → newForwarder... 回 nil + + code, status := getDeviceStatus(t, r, "dev1") + require.Equal(t, http.StatusOK, code) + assert.Equal(t, device.USBStatusOnline, status) +} + +// TestResolveDriverStatusFetcher_NilWhenNoForwarder 驗證:Forwarder 為 nil 時 +// default fetcher 為 nil(handler 據此略過即時查詢)。 +func TestResolveDriverStatusFetcher_NilWhenNoForwarder(t *testing.T) { + assert.Nil(t, resolveDriverStatusFetcher(Deps{SessionStore: &fakeSessionStore{}}), + "Forwarder 為 nil 應回 nil fetcher") + assert.Nil(t, resolveDriverStatusFetcher(Deps{}), + "Forwarder + SessionStore 皆 nil 應回 nil fetcher") +} + +// TestResolveDriverStatusFetcher_InjectedWins 驗證:Deps.DriverStatusFetcher 非 nil 時 +// 優先用注入的 stub(不 fallback default)。 +func TestResolveDriverStatusFetcher_InjectedWins(t *testing.T) { + stub := &stubDriverStatusFetcher{} + got := resolveDriverStatusFetcher(Deps{DriverStatusFetcher: stub}) + assert.Same(t, stub, got) +} diff --git a/visionA-backend/internal/api/devices.go b/visionA-backend/internal/api/devices.go index 1935483..0206afa 100644 --- a/visionA-backend/internal/api/devices.go +++ b/visionA-backend/internal/api/devices.go @@ -136,8 +136,15 @@ func devicesListHandler(deps Deps) gin.HandlerFunc { // devicesGetHandler 實作 GET /api/devices/:id。 // -// 雛形:直接從 DeviceRepo 讀(不走 tunnel)。ownership 檢查以 OwnerUserID 比對。 -// 若要即時查 USB 狀態,前端可再打 POST /api/devices/:id/connect 等 proxy 端點。 +// 資料源(方案 Y-2,driver-status-source-gap-diagnosis.md): +// 1. DB metadata + tunnel 狀態(DeviceRepo + SessionStore):id/serial/type/name/ +// remoteStatus/tunnel_online/lastSeenAt/... — 這些只有雲端 DB 有。 +// 2. **額外 proxy 一次 local agent GET /api/devices/:serial 拿即時 driver status**, +// 把即時值(detected/connected/flashing/...)覆蓋到回應的 status 欄,供前端 gate +// isDriverConnected 判斷。走 serial 路由(ADR-018 / WP-C)。 +// +// graceful fallback:device 無序號 / proxy 失敗 / tunnel 離線 / timeout → 用 DB 的靜態 +// status,GET :id 照常回 200(不掛)。driver status 是加值,拿不到不能讓詳情頁整條失敗。 func devicesGetHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.DeviceRepo == nil { @@ -201,6 +208,27 @@ func devicesGetHandler(deps Deps) gin.HandlerFunc { ls := lastSeen item.LastSeenAt = &ls } + + // 方案 Y-2:額外打 local agent 拿即時 driver status,覆蓋 DB 靜態值。 + // 只在「device 有序號」時嘗試(serial 路由;無序號本來就不支援即時查詢)。 + // 任何失敗都 graceful fallback:保留 item.USBStatus 的 DB 值,GET :id 照常回 200。 + if d.SerialNumber != "" { + if fetcher := resolveDriverStatusFetcher(deps); fetcher != nil { + live, ferr := fetcher.FetchDriverStatus(c.Request.Context(), userID, d.SerialNumber) + if ferr == nil && live != "" { + item.USBStatus = live // 即時 driver status 覆蓋 DB 靜態值 + } else if ferr != nil { + // fallback:保留 DB status。記 debug log 供排查(不是錯誤、不告警)。 + logOrDefault(deps.Logger).Debug("devices: live driver status unavailable, fallback to DB status", + "device_id", d.ID, + "serial", d.SerialNumber, + "db_status", item.USBStatus, + "error", ferr.Error(), + "request_id", RequestIDFrom(c)) + } + } + } + WriteSuccess(c, http.StatusOK, item) } } diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx index 18b6000..52110ea 100644 --- a/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx @@ -317,6 +317,18 @@ describe("DeviceDetailClient 兩步式連線 gate(方案 A / ADR-018)", () = expect(screen.getByTestId("device-driver-status")).toHaveTextContent("連線中"); }); + it("driver status = unknown(tunnel 離線 / proxy fallback):顯示翻譯「未確認」而非 raw key", () => { + useDeviceStore.setState({ + selectedDevice: { ...detectedDevice, status: "unknown" }, + }); + renderDetail(); + + const driverStatus = screen.getByTestId("device-driver-status"); + expect(driverStatus).toHaveTextContent("未確認"); + // 防止回歸:不得顯示 i18n key 字面值。 + expect(driverStatus).not.toHaveTextContent("devices.status.unknown"); + }); + it("點「斷線」→ 呼叫 disconnectDevice(serial) → fetchDevice(id) → toast", async () => { const disconnectSpy = vi .spyOn(useDeviceStore.getState(), "disconnectDevice") diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts index 8b867d4..adcf1f2 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts @@ -159,6 +159,7 @@ export const en: Dictionary = { "devices.status.inferencing": "Inferencing", "devices.status.error": "Error", "devices.status.disconnected": "Disconnected", + "devices.status.unknown": "Unknown", // ── Devices: serial number (serial routing, ADR-018 / WP-C) ── "devices.serial.label": "Serial number", diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts index c4bd560..82b66d1 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts @@ -160,6 +160,7 @@ export const zhHant: Dictionary = { "devices.status.inferencing": "推論中", "devices.status.error": "錯誤", "devices.status.disconnected": "未連接", + "devices.status.unknown": "未確認", // ── Devices: 序號(serial 路由,ADR-018 / WP-C) ── "devices.serial.label": "序號", diff --git a/visionA-frontend/src/stores/device-store.test.ts b/visionA-frontend/src/stores/device-store.test.ts index b9a316d..d508a2d 100644 --- a/visionA-frontend/src/stores/device-store.test.ts +++ b/visionA-frontend/src/stores/device-store.test.ts @@ -203,6 +203,28 @@ describe("useDeviceStore", () => { expect(devices[4]?.serialNumber).toBeNull(); }); + it("status 正規化:缺欄位 / 非已知七值 → unknown(防呆,避免透傳 raw key)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ + success: true, + data: [ + // 合法七值之一 → 原樣保留 + { id: "dev-1", name: "A", type: "kl520", status: "connected" }, + // status 缺欄位(proxy fallback / tunnel 離線)→ unknown + { id: "dev-2", name: "B", type: "kl520" }, + // 非已知值(後端未預期字面值)→ unknown,不透傳 + { id: "dev-3", name: "C", type: "kl520", status: "weird_state" }, + ], + }), + ); + + await useDeviceStore.getState().fetchDevices(); + const { devices } = useDeviceStore.getState(); + expect(devices[0]?.status).toBe("connected"); + expect(devices[1]?.status).toBe("unknown"); + expect(devices[2]?.status).toBe("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 159b9af..2395e0e 100644 --- a/visionA-frontend/src/stores/device-store.ts +++ b/visionA-frontend/src/stores/device-store.ts @@ -39,11 +39,37 @@ export type DeviceHardwareStatus = | "flashing" | "inferencing" | "error" - | "disconnected"; + | "disconnected" + // status 拿不到 / tunnel 離線 / proxy fallback / 非上列七值時的防呆值。 + // 有對應 i18n key `devices.status.unknown`,UI 顯示「未確認」而非 key 字面值。 + | "unknown"; /** 遠端 tunnel 層級狀態(local agent ↔ 雲端) */ export type RemoteStatus = "online" | "offline" | "reconnecting" | "error" | "unknown"; +/** + * DeviceHardwareStatus 的已知值集合(不含 unknown)。normalizeDevice 用來驗證後端 + * 回傳的 status:不在集合內(拿不到 / proxy fallback / 非預期值)一律歸 `unknown`, + * 避免把未知字面值透傳給 `t(\`devices.status.${status}\`)` 而顯示成 raw key。 + */ +const KNOWN_HARDWARE_STATUSES: ReadonlySet = new Set([ + "detected", + "connecting", + "connected", + "flashing", + "inferencing", + "error", + "disconnected", +]); + +/** 把後端 status 收斂成合法 DeviceHardwareStatus;無值 / 非已知值 → `unknown`。 */ +function coerceHardwareStatus(raw: string | undefined): DeviceHardwareStatus { + if (raw != null && KNOWN_HARDWARE_STATUSES.has(raw)) { + return raw as DeviceHardwareStatus; + } + return "unknown"; +} + /** 列表用的精簡裝置資訊 */ export interface DeviceSummary { id: string; @@ -123,7 +149,7 @@ function normalizeDevice(raw: unknown): Device { alias: pick("alias") ?? undefined, serialNumber: serial !== "" ? serial : null, type: String(pick("type", "device_type") ?? ""), - status: (pick("status") as DeviceHardwareStatus) ?? "disconnected", + status: coerceHardwareStatus(pick("status")), remoteStatus: tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"), lastSeenAt: pick("last_seen_at", "lastSeenAt") ?? null,