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:
parent
1da385f345
commit
67737334c8
@ -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
|
||||
|
||||
|
||||
151
visionA-backend/internal/api/device_driver_status.go
Normal file
151
visionA-backend/internal/api/device_driver_status.go
Normal 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 與靜態 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)
|
||||
}
|
||||
204
visionA-backend/internal/api/device_driver_status_test.go
Normal file
204
visionA-backend/internal/api/device_driver_status_test.go
Normal 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 的測試替身。
|
||||
//
|
||||
// 可設定:回傳的即時 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "序號",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<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 {
|
||||
id: string;
|
||||
@ -123,7 +149,7 @@ function normalizeDevice(raw: unknown): Device {
|
||||
alias: pick<string>("alias") ?? undefined,
|
||||
serialNumber: serial !== "" ? serial : null,
|
||||
type: String(pick<string>("type", "device_type") ?? ""),
|
||||
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
|
||||
status: coerceHardwareStatus(pick<string>("status")),
|
||||
remoteStatus:
|
||||
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
|
||||
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user