diff --git a/visionA-frontend/src/app/devices/page.tsx b/visionA-frontend/src/app/devices/page.tsx index 2489561..e717bc7 100644 --- a/visionA-frontend/src/app/devices/page.tsx +++ b/visionA-frontend/src/app/devices/page.tsx @@ -19,6 +19,7 @@ import Link from "next/link"; import { Link2, RefreshCw } from "lucide-react"; import { DeviceList } from "@/components/devices/device-list"; +import { DeviceRescanButton } from "@/components/devices/device-rescan-button"; import { Button } from "@/components/ui/button"; import { useT } from "@/lib/i18n/context"; import { useDeviceStore } from "@/stores/device-store"; @@ -54,6 +55,9 @@ export default function DevicesPage() { className={`size-4 ${isLoading ? "animate-spin" : ""}`} /> + {/* 重新掃描 USB(觸發 local agent rescan)— 與上方「刷新列表」不同: + 刷新只重讀雲端 DB,rescan 讓剛插入的新 USB 裝置被偵測出來。 */} + + ); +} diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts index 194be1c..30351d2 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts @@ -106,19 +106,19 @@ export const en: Dictionary = { "dashboard.flashes": "Flashes", "dashboard.connectedDevices": "Online devices", "dashboard.noConnectedDevices": - "No devices are online. Pair a Kneron device to start cloud inference.", + "No devices are online. Connect your computer to use Kneron devices from the cloud.", "dashboard.recentActivity": "Recent activity", "dashboard.noActivity": - "Nothing here yet. Activity appears after pairing, uploads, or inference runs.", + "Nothing here yet. Activity appears after connecting a computer, uploads, or inference runs.", "dashboard.quickActions": "Quick actions", "dashboard.browseModels": "Browse models", "dashboard.manageDevices": "Manage devices", "dashboard.uploadModel": "Upload model", - "dashboard.pairDevice": "Pair device", + "dashboard.pairDevice": "Connect computer", "dashboard.empty.title": "No devices yet", "dashboard.empty.description": - "Pair your first Kneron device to start running inference from anywhere.", - "dashboard.empty.action": "Pair a device", + "Connect your computer to start using Kneron devices from anywhere.", + "dashboard.empty.action": "Connect computer", "dashboard.activity.justNow": "just now", "dashboard.activity.minutesAgo": "{n} minutes ago", "dashboard.activity.hoursAgo": "{n} hours ago", @@ -131,13 +131,13 @@ export const en: Dictionary = { "devices.firmware": "Firmware", "devices.flashedModel": "Flashed model", "devices.openWorkspace": "Open workspace", - "devices.addMore": "Pair a new device", - "devices.pairAction": "Pair a new device", - "devices.empty.title": "No devices paired yet", + "devices.addMore": "Connect a new computer", + "devices.pairAction": "Connect a new computer", + "devices.empty.title": "No computers connected yet", "devices.empty.description": - "Run local agent on your computer and complete pairing to access your Kneron devices from anywhere.", - "devices.empty.action": "Pair your first device", - "devices.empty.secondaryAction": "How pairing works", + "Run local agent on your computer and complete the connection to access your Kneron devices from anywhere.", + "devices.empty.action": "Connect your computer", + "devices.empty.secondaryAction": "How connecting works", "devices.detail.id": "ID", "devices.detail.type": "Type", "devices.detail.firmware": "Firmware", @@ -146,7 +146,7 @@ export const en: Dictionary = { "devices.detail.modelStatus": "Model status", "devices.detail.readyForInference": "Ready for inference", "devices.detail.noModelFlashed": "No model has been flashed", - "devices.detail.pairedAt": "Paired at", + "devices.detail.pairedAt": "Connected at", "devices.detail.hostName": "Host", "devices.detail.lastSeen": "Last seen", "devices.detail.offlineBanner.title": "This device is offline", @@ -165,14 +165,14 @@ export const en: Dictionary = { "devices.serial.label": "Serial number", "devices.serial.missing": "Serial not reported yet", "devices.serial.missingHint": - "This device hasn't reported its serial number, so inference-related actions are unavailable. Re-pair it once from local agent to report the serial.", + "This device hasn't reported its serial number, so inference-related actions are unavailable. Re-connect once from local agent to report the serial.", // ── Devices: remove (unpair) ── "devices.remove.action": "Remove device", "devices.remove.removing": "Removing…", "devices.remove.confirm.title": "Remove this device?", "devices.remove.confirm.description": - "This unpairs “{name}” from your account and revokes its access. To use it again, you'll need to pair it from local agent. This cannot be undone.", + "This disconnects “{name}” from your account and revokes its access. To use it again, you'll need to connect it from local agent. This cannot be undone.", "devices.remove.confirm.action": "Remove", "devices.remove.toast.success": "Device removed", "devices.remove.error.title": "Couldn't remove device", @@ -217,6 +217,16 @@ export const en: Dictionary = { "Try a different filter, or clear it to see all your devices.", "devices.filter.empty.action": "Clear filter", + // ── Devices: rescan USB (re-detect newly plugged devices) ── + "devices.rescan.action": "Rescan devices", + "devices.rescan.pending": "Scanning…", + "devices.rescan.toast.success": "Devices rescanned", + "devices.rescan.error.title": "Couldn't rescan devices", + "devices.rescan.error.TUNNEL_DISCONNECTED": + "The local agent is offline. Make sure it's running and connected, then try again.", + "devices.rescan.error.unknown": "Something went wrong. Please try again.", + "devices.rescan.offlineHint": "Connect a local agent to rescan for USB devices.", + // ── Devices: flash (load model to device) ── "devices.flash.flashModel": "Load model", "devices.flash.flashToDevice": "Load a model to this device", @@ -279,7 +289,7 @@ export const en: Dictionary = { "models.filters.all": "All", "models.empty.title": "No models yet", "models.empty.description": - "Upload your first .nef model to deploy it to any paired Kneron device.", + "Upload your first .nef model to deploy it to any Kneron device on a connected computer.", "models.empty.action": "Upload your first model", "models.section.preset": "Preset models", "models.section.converted": "Converted by you", @@ -423,7 +433,7 @@ export const en: Dictionary = { "workspace.subtitle": "Select an online device to start inference", "workspace.empty.title": "No devices are online", "workspace.empty.description": - "Pair a device and make sure the local agent is connected to the cloud.", + "Connect your computer and make sure the local agent is connected to the cloud.", "workspace.empty.action": "Go to devices", "workspace.header.backToDevices": "Back to devices", "workspace.header.title": "Workspace", @@ -452,7 +462,7 @@ export const en: Dictionary = { "workspace.offline.backToList": "Back to devices", "workspace.noSerial.title": "This device hasn't reported a serial number", "workspace.noSerial.description": - "Inference, camera, and media upload need the device serial to route to local agent. Re-pair the device once from local agent; these actions unlock after the serial is reported.", + "Inference, camera, and media upload need the device serial to route to local agent. Re-connect once from local agent; these actions unlock after the serial is reported.", "workspace.tabs.camera": "Camera", "workspace.tabs.image": "Image", "workspace.tabs.video": "Video", @@ -509,35 +519,35 @@ export const en: Dictionary = { "settings.advanced.platform": "Platform", // ── Pairing (F7) ── - "pairing.title": "Pair a new device", + "pairing.title": "Connect this computer", "pairing.subtitle": - "Connect your Kneron device to the cloud so you can operate it from anywhere.", - "pairing.token.title": "Your pairing token", + "Connect this computer to the cloud so its Kneron devices can be operated from anywhere.", + "pairing.token.title": "Your connection token", "pairing.step1.description": - "Copy the token below and paste it into your local agent within 15 minutes.", + "Copy the connection token below and paste it into your local agent within 15 minutes.", "pairing.copy": "Copy", "pairing.copied": "Copied", "pairing.regenerate": "Regenerate", "pairing.timeRemaining": "{time} remaining", "pairing.generatedAt": "Generated at {time}", - "pairing.token.expired.label": "This token has expired — please regenerate.", - "pairing.regenerateConfirm.title": "Regenerate token?", + "pairing.token.expired.label": "This connection token has expired — please regenerate.", + "pairing.regenerateConfirm.title": "Regenerate connection token?", "pairing.regenerateConfirm.description": - "The old token will be invalidated immediately; the new one is valid for 15 minutes.", + "The old connection token will be invalidated immediately; the new one is valid for 15 minutes.", "pairing.security.warning": - "This token is valid for 15 minutes — complete pairing now.", + "This connection token is valid for 15 minutes — complete the connection now.", "pairing.security.oneTime": - "Tokens are single-use and expire automatically after pairing.", - "pairing.toast.copied": "Token copied — valid for 15 minutes.", - "pairing.toast.generateFailed": "Could not generate token — please retry.", + "Connection tokens are single-use and expire automatically after connecting.", + "pairing.toast.copied": "Connection token copied — valid for 15 minutes.", + "pairing.toast.generateFailed": "Could not generate connection token — please retry.", "pairing.toast.expiringSoon": - "Token expiring soon — complete pairing or regenerate.", - "pairing.toast.pairedSuccess": "Device {deviceName} paired successfully.", + "Connection token expiring soon — complete the connection or regenerate.", + "pairing.toast.pairedSuccess": "Computer connected — device {deviceName} detected.", "pairing.toast.cliCopied": "CLI command copied.", "pairing.device.unknown": "Unknown device", "pairing.cli.title": "CLI example", "pairing.cli.description": - "Start local agent on your computer and pass the token to the --relay-token flag.", + "Start local agent on your computer and pass the connection token to the --relay-token flag.", "pairing.cli.copy": "Copy command", "pairing.cli.hint": "Once local agent connects to the cloud, this page detects it and forwards you to the device list.", @@ -545,7 +555,7 @@ export const en: Dictionary = { "pairing.step3.elapsed": "Elapsed {time} (max 3 minutes)", "pairing.step3.hints.running": "Confirm local agent is running", "pairing.step3.hints.token": - "Confirm the token was pasted without missing or extra characters", + "Confirm the connection token was pasted without missing or extra characters", "pairing.step3.hints.network": "Confirm your network can reach the cloud endpoint", "pairing.step3.success": "Connected!", diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts index ecfef89..7f43497 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts @@ -109,17 +109,17 @@ export const zhHant: Dictionary = { "dashboard.connected": "線上裝置", "dashboard.flashes": "已燒錄次數", "dashboard.connectedDevices": "線上裝置", - "dashboard.noConnectedDevices": "目前沒有裝置線上。配對一台 Kneron 裝置開始雲端推論。", + "dashboard.noConnectedDevices": "目前沒有裝置線上。連接你的電腦,就能從雲端使用 Kneron 裝置。", "dashboard.recentActivity": "近期活動", - "dashboard.noActivity": "還沒有任何活動。配對裝置、上傳模型或跑一次推論後就會出現。", + "dashboard.noActivity": "還沒有任何活動。連接電腦、上傳模型或跑一次推論後就會出現。", "dashboard.quickActions": "快速操作", "dashboard.browseModels": "瀏覽模型", "dashboard.manageDevices": "管理裝置", "dashboard.uploadModel": "上傳模型", - "dashboard.pairDevice": "配對裝置", + "dashboard.pairDevice": "連接電腦", "dashboard.empty.title": "還沒有任何裝置", - "dashboard.empty.description": "配對你的第一台 Kneron 裝置,開始雲端推論之旅", - "dashboard.empty.action": "配對裝置", + "dashboard.empty.description": "連接你的電腦,開始從雲端使用 Kneron 裝置", + "dashboard.empty.action": "連接電腦", "dashboard.activity.justNow": "剛剛", "dashboard.activity.minutesAgo": "{n} 分鐘前", "dashboard.activity.hoursAgo": "{n} 小時前", @@ -132,13 +132,13 @@ export const zhHant: Dictionary = { "devices.firmware": "韌體", "devices.flashedModel": "已燒錄模型", "devices.openWorkspace": "開啟工作區", - "devices.addMore": "配對新裝置", - "devices.pairAction": "配對新裝置", - "devices.empty.title": "還沒有配對的裝置", + "devices.addMore": "連接新電腦", + "devices.pairAction": "連接新電腦", + "devices.empty.title": "還沒有連接任何電腦", "devices.empty.description": - "在你的電腦上執行 local agent 並完成配對,就能從任何地方存取你的 Kneron 裝置", - "devices.empty.action": "配對第一台裝置", - "devices.empty.secondaryAction": "查看配對說明", + "在你的電腦上執行 local agent 並完成連接,就能從任何地方存取你的 Kneron 裝置", + "devices.empty.action": "連接你的電腦", + "devices.empty.secondaryAction": "查看連接說明", "devices.detail.id": "ID", "devices.detail.type": "類型", "devices.detail.firmware": "韌體", @@ -147,7 +147,7 @@ export const zhHant: Dictionary = { "devices.detail.modelStatus": "模型狀態", "devices.detail.readyForInference": "已就緒,可開始推論", "devices.detail.noModelFlashed": "尚未燒錄任何模型", - "devices.detail.pairedAt": "配對時間", + "devices.detail.pairedAt": "連接時間", "devices.detail.hostName": "所在電腦", "devices.detail.lastSeen": "最後心跳", "devices.detail.offlineBanner.title": "此裝置目前離線", @@ -166,14 +166,14 @@ export const zhHant: Dictionary = { "devices.serial.label": "序號", "devices.serial.missing": "尚未回報序號", "devices.serial.missingHint": - "此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新配對一次,序號回報後即可使用。", + "此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新連接一次,序號回報後即可使用。", // ── Devices: 移除裝置(unpair) ── "devices.remove.action": "移除裝置", "devices.remove.removing": "移除中…", "devices.remove.confirm.title": "確定要移除此裝置?", "devices.remove.confirm.description": - "這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。", + "這會解除「{name}」與你帳號的連接並撤銷其存取權限。若要再次使用,需從 local agent 重新連接。此操作無法復原。", "devices.remove.confirm.action": "移除", "devices.remove.toast.success": "已移除裝置", "devices.remove.error.title": "移除裝置失敗", @@ -216,6 +216,16 @@ export const zhHant: Dictionary = { "devices.filter.empty.description": "試試其他篩選條件,或清除篩選以顯示所有裝置。", "devices.filter.empty.action": "清除篩選", + // ── Devices: 重新掃描 USB(偵測新插入的裝置) ── + "devices.rescan.action": "重新掃描裝置", + "devices.rescan.pending": "掃描中…", + "devices.rescan.toast.success": "已重新掃描", + "devices.rescan.error.title": "重新掃描失敗", + "devices.rescan.error.TUNNEL_DISCONNECTED": + "local agent 目前離線。請確認它正在執行並已連線,再試一次。", + "devices.rescan.error.unknown": "發生錯誤,請再試一次。", + "devices.rescan.offlineHint": "請先連接 local agent,才能重新掃描 USB 裝置。", + // ── Devices: flash(載入模型到裝置) ── "devices.flash.flashModel": "載入模型", "devices.flash.flashToDevice": "載入模型到此裝置", @@ -277,7 +287,7 @@ export const zhHant: Dictionary = { "models.filters.all": "全部", "models.empty.title": "還沒有任何模型", "models.empty.description": - "上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置", + "上傳你的第一個 .nef 模型到雲端,就能部署到任何一台已連接電腦上的 Kneron 裝置", "models.empty.action": "上傳第一個模型", "models.section.preset": "預設模型", "models.section.converted": "我轉檔的", @@ -412,7 +422,7 @@ export const zhHant: Dictionary = { "workspace.title": "推論工作區", "workspace.subtitle": "選擇已線上的裝置開始推論", "workspace.empty.title": "目前沒有線上裝置", - "workspace.empty.description": "請先配對並確認 local agent 已連上雲端", + "workspace.empty.description": "請先連接你的電腦並確認 local agent 已連上雲端", "workspace.empty.action": "前往裝置管理", "workspace.header.backToDevices": "返回裝置", "workspace.header.title": "工作區", @@ -438,7 +448,7 @@ export const zhHant: Dictionary = { "workspace.offline.backToList": "返回裝置列表", "workspace.noSerial.title": "此裝置尚未回報序號", "workspace.noSerial.description": - "推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新配對一次,序號回報後即可操作。", + "推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新連接一次,序號回報後即可操作。", "workspace.tabs.camera": "Camera", "workspace.tabs.image": "圖片", "workspace.tabs.video": "影片", @@ -494,42 +504,42 @@ export const zhHant: Dictionary = { "settings.advanced.platform": "平台", // ── Pairing(F7 新增)── - "pairing.title": "配對新裝置", - "pairing.subtitle": "讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作", - "pairing.token.title": "你的 Pairing Token", + "pairing.title": "連接這台電腦", + "pairing.subtitle": "讓這台電腦連上雲端,之後電腦上的 Kneron 裝置都能從任何地方遠端操作", + "pairing.token.title": "你的連接碼", "pairing.step1.description": - "複製下方 token,在 15 分鐘內貼到 local agent 完成配對", + "複製下方連接碼,在 15 分鐘內貼到 local agent 完成連接", "pairing.copy": "複製", "pairing.copied": "已複製", "pairing.regenerate": "重新產生", "pairing.timeRemaining": "剩餘 {time}", "pairing.generatedAt": "產生時間:{time}", - "pairing.token.expired.label": "此 token 已過期,請重新產生", - "pairing.regenerateConfirm.title": "確定要重新產生?", + "pairing.token.expired.label": "此連接碼已過期,請重新產生", + "pairing.regenerateConfirm.title": "確定要重新產生連接碼?", "pairing.regenerateConfirm.description": - "舊 token 將立即失效,新 token 有效期 15 分鐘", - "pairing.security.warning": "這組 token 15 分鐘內有效,請立刻完成配對", - "pairing.security.oneTime": "token 是一次性使用,完成配對後自動失效", - "pairing.toast.copied": "Token 已複製到剪貼簿,15 分鐘內有效", - "pairing.toast.generateFailed": "無法產生 token,請重試", - "pairing.toast.expiringSoon": "Token 即將過期,請立刻完成或重新產生", - "pairing.toast.pairedSuccess": "裝置 {deviceName} 已成功配對", + "舊連接碼將立即失效,新連接碼有效期 15 分鐘", + "pairing.security.warning": "這組連接碼 15 分鐘內有效,請立刻完成連接", + "pairing.security.oneTime": "連接碼是一次性使用,完成連接後自動失效", + "pairing.toast.copied": "連接碼已複製到剪貼簿,15 分鐘內有效", + "pairing.toast.generateFailed": "無法產生連接碼,請重試", + "pairing.toast.expiringSoon": "連接碼即將過期,請立刻完成或重新產生", + "pairing.toast.pairedSuccess": "已成功連接電腦,偵測到裝置 {deviceName}", "pairing.toast.cliCopied": "指令已複製到剪貼簿", "pairing.device.unknown": "未知裝置", "pairing.cli.title": "CLI 指令範例", "pairing.cli.description": - "在你的電腦啟動 local agent,將 token 貼到指令的 --relay-token 參數", + "在你的電腦啟動 local agent,將連接碼貼到指令的 --relay-token 參數", "pairing.cli.copy": "複製指令", "pairing.cli.hint": "local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表", "pairing.step3.waiting": "等待 local agent 連線…", "pairing.step3.elapsed": "已等待 {time}(最長 3 分鐘)", "pairing.step3.hints.running": "確認 local agent 已啟動", - "pairing.step3.hints.token": "確認 token 貼上時無缺字或多餘空白", + "pairing.step3.hints.token": "確認連接碼貼上時無缺字或多餘空白", "pairing.step3.hints.network": "確認你的網路可連線到雲端", - "pairing.step3.success": "已成功連線!", + "pairing.step3.success": "已成功連接電腦!", "pairing.step3.success.detected": "檢測到的裝置", - "pairing.step3.failure.timeout": "連線超時", + "pairing.step3.failure.timeout": "連接超時", "pairing.step3.failure.reason": "超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動", "pairing.step3.failure.retry": "重新檢查", diff --git a/visionA-frontend/src/stores/device-store.test.ts b/visionA-frontend/src/stores/device-store.test.ts index 91ce6fe..73d3336 100644 --- a/visionA-frontend/src/stores/device-store.test.ts +++ b/visionA-frontend/src/stores/device-store.test.ts @@ -24,6 +24,7 @@ beforeEach(() => { disconnectingId: null, unpairingId: null, registeringId: null, + isRescanning: false, error: null, }); // OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶) @@ -663,3 +664,101 @@ describe("useDeviceStore.unregisterDevice", () => { expect(useDeviceStore.getState().registeringId).toBeNull(); }); }); + +describe("useDeviceStore.rescanDevices", () => { + it("成功時打對 scan endpoint(POST)、接著 fetchDevices refresh、回 { ok:true }", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + // 1) POST /api/devices/scan + .mockResolvedValueOnce(jsonResponse({ success: true, data: {} })) + // 2) 後續 fetchDevices 的 GET /api/devices(回一台新插入的裝置) + .mockResolvedValueOnce( + jsonResponse({ + success: true, + data: [ + { + id: "dev-new", + name: "KL630", + type: "kl630", + status: "detected", + remote_status: "online", + }, + ], + }), + ); + + const result = await useDeviceStore.getState().rescanDevices(); + + expect(result).toEqual({ ok: true }); + // 第一發是 scan(POST),第二發是 fetchDevices(GET)。 + expect(String(fetchSpy.mock.calls[0]?.[0])).toContain("/api/devices/scan"); + expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" }); + expect(String(fetchSpy.mock.calls[1]?.[0])).toContain("/api/devices"); + // 掃描後列表被 refresh,新裝置出現。 + const state = useDeviceStore.getState(); + expect(state.devices.map((d) => d.id)).toEqual(["dev-new"]); + expect(state.isRescanning).toBe(false); + }); + + it("呼叫期間 isRescanning 設為 true(loading 態),完成後清回 false", async () => { + let seenDuringScan = false; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + // 在 scan 請求進行中觀察 loading 態。 + if (String(input).includes("/api/devices/scan")) { + seenDuringScan = useDeviceStore.getState().isRescanning; + } + return jsonResponse({ success: true, data: [] }); + }); + + await useDeviceStore.getState().rescanDevices(); + + expect(seenDuringScan).toBe(true); + expect(useDeviceStore.getState().isRescanning).toBe(false); + }); + + it("裝置離線(502 TUNNEL_DISCONNECTED)→ 回 { ok:false, code:'TUNNEL_DISCONNECTED' },不 refresh、不清空列表", async () => { + useDeviceStore.setState({ + devices: [ + { + id: "dev-1", + name: "KL520", + type: "kl520", + status: "connected", + remoteStatus: "online", + }, + ], + }); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse( + { + success: false, + error: { code: "TUNNEL_DISCONNECTED", message: "agent offline" }, + }, + 502, + ), + ); + + const result = await useDeviceStore.getState().rescanDevices(); + + expect(result).toMatchObject({ ok: false, code: "TUNNEL_DISCONNECTED" }); + // 只打了 scan 一發,沒有接著 fetchDevices(失敗不 refresh)。 + expect(fetchSpy).toHaveBeenCalledOnce(); + // 既有列表保留(不清空)。 + expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]); + expect(useDeviceStore.getState().isRescanning).toBe(false); + expect(useDeviceStore.getState().error).toBe("agent offline"); + }); + + it("其他錯誤(500)→ 回 { ok:false, code:'INTERNAL_ERROR' },isRescanning 清回 false", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse( + { success: false, error: { code: "INTERNAL_ERROR", message: "boom" } }, + 500, + ), + ); + + const result = await useDeviceStore.getState().rescanDevices(); + expect(result).toMatchObject({ ok: false, code: "INTERNAL_ERROR" }); + expect(useDeviceStore.getState().isRescanning).toBe(false); + }); +}); diff --git a/visionA-frontend/src/stores/device-store.ts b/visionA-frontend/src/stores/device-store.ts index 608bf9a..14b8eb7 100644 --- a/visionA-frontend/src/stores/device-store.ts +++ b/visionA-frontend/src/stores/device-store.ts @@ -203,6 +203,19 @@ export type RegisterResult = | { ok: true } | { ok: false; code: string; message: string }; +/** + * rescan(重新掃描 USB 裝置)action 的回傳。 + * + * 採「回 code 而非 boolean」的範式(比照 unpair / register),讓 UI 對不同錯誤分流顯示 toast: + * - scan 是 tunnel proxy 操作(雲端轉發 local agent 的 ScanDevices → Rescan); + * 裝置離線(無 online tunnel)時後端回 502 `TUNNEL_DISCONNECTED`,UI 需針對此顯示 + * 「請確認 local agent 在線」而非泛用錯誤,故帶 code 出來。 + * 成功後由呼叫端接著 fetchDevices() refresh 列表(新插入的裝置就會出現)。 + */ +export type RescanResult = + | { ok: true } + | { ok: false; code: string; message: string }; + interface DeviceState { devices: DeviceSummary[]; selectedDevice: Device | null; @@ -214,6 +227,8 @@ interface DeviceState { unpairingId: string | null; /** 註冊 / 取消註冊進行中的裝置 id(UI 顯示 button spinner);不使用就是 null */ registeringId: string | null; + /** 重新掃描 USB 裝置進行中(UI 顯示按鈕 spinner + disable);不使用就是 false */ + isRescanning: boolean; error: string | null; /** 呼叫 `GET /api/devices` */ @@ -246,13 +261,23 @@ interface DeviceState { * ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只清 registeredAt、device 仍在清單顯示為未註冊。 */ unregisterDevice: (id: string) => Promise; + /** + * 呼叫 `POST /api/devices/scan`(雲端 proxy 轉發 local agent 的 ScanDevices → Rescan, + * 重新偵測 USB:含新插入 / 拔除)。成功後自動接 fetchDevices() 刷新列表, + * 讓剛插入的新裝置立即出現(不必重啟 local agent)。 + * + * ⚠️ scan 是 tunnel proxy 操作:無 online tunnel(裝置離線)時後端回 502 + * `TUNNEL_DISCONNECTED`,回 { ok:false, code:"TUNNEL_DISCONNECTED" } 讓 UI 分流提示。 + * 失敗時不呼叫 fetchDevices(維持既有列表,不清空)。 + */ + rescanDevices: () => Promise; /** 測試 / 雛形用:直接塞 list */ _setDevices: (devices: DeviceSummary[]) => void; /** 測試 / 雛形用:直接塞 selected */ _setSelected: (device: Device | null) => void; } -export const useDeviceStore = create()((set) => ({ +export const useDeviceStore = create()((set, get) => ({ devices: [], selectedDevice: null, isLoading: false, @@ -260,6 +285,7 @@ export const useDeviceStore = create()((set) => ({ disconnectingId: null, unpairingId: null, registeringId: null, + isRescanning: false, error: null, fetchDevices: async () => { @@ -416,6 +442,28 @@ export const useDeviceStore = create()((set) => ({ } }, + rescanDevices: async () => { + set({ isRescanning: true, error: null }); + try { + // POST /api/devices/scan:雲端 proxy 透傳 local agent 的 ScanDevices → Rescan。 + // 回傳的 scan 結果本身不需要(新裝置由後續 fetchDevices 從 DB / tunnel 帶出), + // 成功與否由有無 throw 判定(比照 unpair 範式)。 + await api.post("/api/devices/scan"); + set({ isRescanning: false }); + // 掃描完成後 refresh 列表——剛插入的新裝置就會出現。 + // 這裡 await 讓 UI 的 loading 能延續到列表更新完(fetchDevices 自行管理 isLoading)。 + await get().fetchDevices(); + return { ok: true }; + } catch (err) { + // ApiError 帶 backend code(TUNNEL_DISCONNECTED / INTERNAL_ERROR / …)給 UI 分流 toast; + // 其他例外(網路層)退化成 unknown。失敗時不呼叫 fetchDevices(維持既有列表)。 + const message = err instanceof Error ? err.message : String(err); + const code = err instanceof ApiError ? err.code : "unknown"; + set({ isRescanning: false, error: message }); + return { ok: false, code, message }; + } + }, + _setDevices: (devices) => set({ devices }), _setSelected: (selectedDevice) => set({ selectedDevice }), }));