fix(device): GET /api/devices/:id proxy 拿即時 driver status(方案 Y-2,解載入模型 disabled)

問題:連線後裝置詳情頁連線狀態顯示 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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-17 09:25:26 +08:00
parent 1da385f345
commit 67737334c8
9 changed files with 455 additions and 4 deletions

View File

@ -86,6 +86,12 @@ type Deps struct {
SessionStore session.Store SessionStore session.Store
Forwarder *session.Forwarder 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 DeviceRepo device.Repository
ModelRepo model.Repository ModelRepo model.Repository

View File

@ -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 與靜態 USBStatusonline/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 的靜態 statusGET :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。
//
// 刻意設短2sdriver status 是詳情頁的加值資訊,拿不到就 fallback DB status。
// 不能讓 local agent hang 住時把整個詳情頁載入拖到跟 defaultProxyRequestTimeout(300s) 一樣久。
const driverStatusProxyTimeout = 2 * time.Second
// driverStatusFetcher 抽象「向 local agent 查某序號的即時 driver status」。
//
// 回傳的 string 是 local agent DeviceInfo.Statusdriver 七態,如 "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.StatuscamelCase 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 時回 nilcaller 據此略過即時查詢、只回 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 tokenpickActiveSessionToken
// 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) {
// 短 timeoutdriver 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-Clocal 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 組 defaultproduction
// - 兩者皆缺 → 回 nilhandler 略過即時查詢、只回 DB status
func resolveDriverStatusFetcher(deps Deps) driverStatusFetcher {
if deps.DriverStatusFetcher != nil {
return deps.DriverStatusFetcher
}
return newForwarderDriverStatusFetcher(deps)
}

View File

@ -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 的測試替身。
//
// 可設定:回傳的即時 statusliveStatus、回傳的 errorerr並記錄呼叫參數
// 用來驗證 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 回 errorlocal 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 驗證:沒有 fetcherForwarder/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) // 不注入 fetcherfakeSessionStore 存在但 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 為 nilhandler 據此略過即時查詢)。
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)
}

View File

@ -136,8 +136,15 @@ func devicesListHandler(deps Deps) gin.HandlerFunc {
// devicesGetHandler 實作 GET /api/devices/:id。 // devicesGetHandler 實作 GET /api/devices/:id。
// //
// 雛形:直接從 DeviceRepo 讀(不走 tunnel。ownership 檢查以 OwnerUserID 比對。 // 資料源(方案 Y-2driver-status-source-gap-diagnosis.md
// 若要即時查 USB 狀態,前端可再打 POST /api/devices/:id/connect 等 proxy 端點。 // 1. DB metadata + tunnel 狀態DeviceRepo + SessionStoreid/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 fallbackdevice 無序號 / proxy 失敗 / tunnel 離線 / timeout → 用 DB 的靜態
// statusGET :id 照常回 200不掛。driver status 是加值,拿不到不能讓詳情頁整條失敗。
func devicesGetHandler(deps Deps) gin.HandlerFunc { func devicesGetHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if deps.DeviceRepo == nil { if deps.DeviceRepo == nil {
@ -201,6 +208,27 @@ func devicesGetHandler(deps Deps) gin.HandlerFunc {
ls := lastSeen ls := lastSeen
item.LastSeenAt = &ls 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) WriteSuccess(c, http.StatusOK, item)
} }
} }

View File

@ -317,6 +317,18 @@ describe("DeviceDetailClient 兩步式連線 gate方案 A / ADR-018", () =
expect(screen.getByTestId("device-driver-status")).toHaveTextContent("連線中"); expect(screen.getByTestId("device-driver-status")).toHaveTextContent("連線中");
}); });
it("driver status = unknowntunnel 離線 / 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 () => { it("點「斷線」→ 呼叫 disconnectDevice(serial) → fetchDevice(id) → toast", async () => {
const disconnectSpy = vi const disconnectSpy = vi
.spyOn(useDeviceStore.getState(), "disconnectDevice") .spyOn(useDeviceStore.getState(), "disconnectDevice")

View File

@ -159,6 +159,7 @@ export const en: Dictionary = {
"devices.status.inferencing": "Inferencing", "devices.status.inferencing": "Inferencing",
"devices.status.error": "Error", "devices.status.error": "Error",
"devices.status.disconnected": "Disconnected", "devices.status.disconnected": "Disconnected",
"devices.status.unknown": "Unknown",
// ── Devices: serial number (serial routing, ADR-018 / WP-C) ── // ── Devices: serial number (serial routing, ADR-018 / WP-C) ──
"devices.serial.label": "Serial number", "devices.serial.label": "Serial number",

View File

@ -160,6 +160,7 @@ export const zhHant: Dictionary = {
"devices.status.inferencing": "推論中", "devices.status.inferencing": "推論中",
"devices.status.error": "錯誤", "devices.status.error": "錯誤",
"devices.status.disconnected": "未連接", "devices.status.disconnected": "未連接",
"devices.status.unknown": "未確認",
// ── Devices: 序號serial 路由ADR-018 / WP-C ── // ── Devices: 序號serial 路由ADR-018 / WP-C ──
"devices.serial.label": "序號", "devices.serial.label": "序號",

View File

@ -203,6 +203,28 @@ describe("useDeviceStore", () => {
expect(devices[4]?.serialNumber).toBeNull(); 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 () => { it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list不記錯誤", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse( jsonResponse(

View File

@ -39,11 +39,37 @@ export type DeviceHardwareStatus =
| "flashing" | "flashing"
| "inferencing" | "inferencing"
| "error" | "error"
| "disconnected"; | "disconnected"
// status 拿不到 / tunnel 離線 / proxy fallback / 非上列七值時的防呆值。
// 有對應 i18n key `devices.status.unknown`UI 顯示「未確認」而非 key 字面值。
| "unknown";
/** 遠端 tunnel 層級狀態local agent ↔ 雲端) */ /** 遠端 tunnel 層級狀態local agent ↔ 雲端) */
export type RemoteStatus = "online" | "offline" | "reconnecting" | "error" | "unknown"; export type RemoteStatus = "online" | "offline" | "reconnecting" | "error" | "unknown";
/**
* DeviceHardwareStatus unknownnormalizeDevice
* status / proxy fallback / `unknown`
* `t(\`devices.status.${status}\`)` raw key
*/
const KNOWN_HARDWARE_STATUSES: ReadonlySet<string> = 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 { export interface DeviceSummary {
id: string; id: string;
@ -123,7 +149,7 @@ function normalizeDevice(raw: unknown): Device {
alias: pick<string>("alias") ?? undefined, alias: pick<string>("alias") ?? undefined,
serialNumber: serial !== "" ? serial : null, serialNumber: serial !== "" ? serial : null,
type: String(pick<string>("type", "device_type") ?? ""), type: String(pick<string>("type", "device_type") ?? ""),
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected", status: coerceHardwareStatus(pick<string>("status")),
remoteStatus: remoteStatus:
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"), tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null, lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,