feat(frontend): 裝置詳情頁連線/斷線按鈕 + 兩步式 gate(解載入模型 device not connected)
問題:device 配對後停在 detected(rescan 偵測到硬體但沒 connect driver), 載入模型被 flash service IsConnected() 擋成 400。根因=前端漏了「連線」按鈕 (POC 有、移植時當 local-tool 專屬刪掉、詳情頁沒補回雲端版)。前端誤用 remoteStatus=online(tunnel) 當 flash 門檻,≠ device driver IsConnected()。 修法(方案 A、兩步式): - device-detail-client.tsx:連線/斷線按鈕 + 三分支 gate(未連線→連線鈕/ 連線中→loading disabled/已連線→斷線鈕+載入模型可用)+ driver status 呈現 - connect 走 serial(對齊 WP-C)、成功後 fetchDevice(UUID) 刷新(C4 混合模型, WS 推送因 backend 501 stub 不通故用輪詢) - 60s connect UX:loading hint「首次連線需載入韌體約1分鐘,請勿關閉頁面」 - FlashDialog gate 收緊 disabled !isOnline → !isOnline || !isDriverConnected - device-store connect 的 api.post 加 timeoutMs:130_000(全域 30s < connect 最壞 65s,單點覆寫;> local agent 120s ctx) - i18n devices.connect.* 13 key 兩語系 - TDD §10.5.1 過時型別修正 + 新增 §10.5.1.1 two-step connect 流程 推論線一併解決:能進 workspace 的裝置必已 connect(原無 IsConnected 檢查 會靜默空跑無框)。 Reviewer 通過(0C/0M/2Mi/3Sug、規格 12/12、gate 三分支全組合走查)。 tsc/eslint 0、vitest 44 passed、next build 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fac07c39d0
commit
1da385f345
@ -582,11 +582,24 @@ interface Device {
|
|||||||
lastSeenAt?: string; // ISO 8601
|
lastSeenAt?: string; // ISO 8601
|
||||||
lastConnectedAt?: string;
|
lastConnectedAt?: string;
|
||||||
|
|
||||||
// USB-level(local agent 上報)
|
// USB-level / driver-level(local agent 上報)
|
||||||
status: 'online' | 'offline' | 'unknown';
|
// ⚠️ 型別更正(2026-07-16):舊版 TDD 誤寫為 'online'|'offline'|'unknown',
|
||||||
|
// 與實際 code 不符。實際型別為 DeviceHardwareStatus(device-store.ts:34-42),
|
||||||
|
// 以 code 為準:
|
||||||
|
status: DeviceHardwareStatus;
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceHardwareStatus(device-store.ts:34-42)— driver 連線狀態的完整 enum
|
||||||
|
type DeviceHardwareStatus =
|
||||||
|
| 'detected' // USB 偵測到、driver 尚未 connect
|
||||||
|
| 'connecting' // driver 正在 connect(重量級操作進行中)
|
||||||
|
| 'connected' // driver 已 connect,可 flash / 推論
|
||||||
|
| 'flashing' // 正在燒錄韌體(已 connect)
|
||||||
|
| 'inferencing' // 正在推論(已 connect)
|
||||||
|
| 'error' // driver 錯誤
|
||||||
|
| 'disconnected'; // driver 未連線
|
||||||
```
|
```
|
||||||
|
|
||||||
前端顯示邏輯:
|
前端顯示邏輯:
|
||||||
@ -594,15 +607,44 @@ interface Device {
|
|||||||
| 畫面位置 | 顯示依據 |
|
| 畫面位置 | 顯示依據 |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| Device 卡片主狀態 badge | **`remoteStatus`**(決定「雲端能不能操作它」)|
|
| Device 卡片主狀態 badge | **`remoteStatus`**(決定「雲端能不能操作它」)|
|
||||||
| Device 卡片副狀態 | `status`(USB 是否插著)— 僅在 `remoteStatus === 'online'` 時顯示有意義 |
|
| Device 卡片副狀態 | `status`(driver 連線狀態、七值 enum)— 僅在 `remoteStatus === 'online'` 時顯示有意義 |
|
||||||
| 「最後上線」時間 | `lastSeenAt`(tunnel 最後心跳) |
|
| 「最後上線」時間 | `lastSeenAt`(tunnel 最後心跳) |
|
||||||
| Inference / Flash 按鈕啟用條件 | `remoteStatus === 'online' && status === 'online'` |
|
| Inference / Flash 按鈕啟用條件 | `remoteStatus === 'online' && status ∈ {connected, flashing, inferencing}`(driver 已 connect;詳見 §10.5.1.1 兩步式 gate) |
|
||||||
| 「重連中」loading | `remoteStatus === 'reconnecting'` |
|
| 「重連中」loading | `remoteStatus === 'reconnecting'` |
|
||||||
| 「離線,請檢查 local-tool」提示 | `remoteStatus === 'offline'` |
|
| 「離線,請檢查 local-tool」提示 | `remoteStatus === 'offline'` |
|
||||||
|
|
||||||
邏輯總結:前端永遠先看 `remoteStatus`(雲端是否可達),再看 `status`(USB 是否接著)。兩者獨立更新,前端要訂閱兩種事件:
|
邏輯總結:前端永遠先看 `remoteStatus`(雲端是否可達),再看 `status`(driver 是否已 connect)。兩者獨立更新:
|
||||||
- `remoteStatus` 變化 → `WS /ws/devices/:id/remote-status`(來自 remote-proxy 轉發)
|
- `remoteStatus` 變化 → `WS /ws/devices/:id/remote-status`(來自 remote-proxy 轉發)
|
||||||
- `status` 變化 → `WS /ws/devices/:id/status`(來自 local agent 的 USB 監聽事件)
|
- `status` 變化 → 目前**輪詢式**(connect / flash 完成後 `fetchDevice` 重拉)。device events WS(`/ws/devices/events`)end-to-end 尚未打通(backend 為 501 stub,屬 B7),故不依賴 WS 推 driver 狀態。詳見 §10.5.1.1。
|
||||||
|
|
||||||
|
#### 10.5.1.1 Driver-level connect(兩步式操作,方案 A)
|
||||||
|
|
||||||
|
Kneron 裝置的 flash / 推論都要求 driver 先 `Connect()`(重量級操作,最壞 60–65s:KL520 reset + firmware reload + reconnect)。雲端前端在 device 詳情頁提供獨立「連線 / 斷線」按鈕承載這一步(對齊 local-tool POC 的兩步式:先 connect device → 再載入模型 / 推論)。
|
||||||
|
|
||||||
|
**driver `status` vs `remoteStatus` 的區別(本次核心)**:
|
||||||
|
|
||||||
|
| 欄位 | 語意 | 值 | 決定什麼 |
|
||||||
|
|------|------|-----|---------|
|
||||||
|
| `remoteStatus` | tunnel-level(雲端能不能觸達 local agent) | `online` / `offline` / `reconnecting` / `error` | local agent 在不在線 |
|
||||||
|
| `status`(driver) | driver-level(Kneron driver 有沒有 `Connect()`) | `DeviceHardwareStatus` 七值 | 能不能 flash / 推論 |
|
||||||
|
|
||||||
|
`remoteStatus === 'online'` 只代表「雲端可下指令給 local agent」;能否 flash / 推論還要看 driver 是否 `connected`(`detected` = USB 偵測到但 driver 未 connect,此時 flash 會被 local agent 擋成 400 `device not connected`)。這是兩步式 connect 的存在理由。
|
||||||
|
|
||||||
|
- **路由**:connect / disconnect 走 **serial 路由**(ADR-018 / WP-C)。`device-store.connectDevice(serial)` / `disconnectDevice(serial)`,path `POST /api/devices/:serial/connect`(`:id` 段填 serial number / kn_number,非雲端 UUID)。雲端純 proxy、零邏輯。
|
||||||
|
|
||||||
|
- **gate 三分支**(前提 `remoteStatus === 'online' && serialNumber` 存在):
|
||||||
|
|
||||||
|
| driver `status` | 連線按鈕 | FlashDialog(載入模型) | 開啟工作區 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `detected` / `disconnected` / `error` | 顯示「連線」(primary) | disable(tooltip「請先連線裝置」) | disable |
|
||||||
|
| `connecting`(或本地 `connectingId`) | loading「連線中…」、disable(防重點) | disable | disable |
|
||||||
|
| `connected` / `flashing` / `inferencing` | 顯示「斷線」(outline) | enable | 沿用 `flashedModel` gate |
|
||||||
|
|
||||||
|
- **狀態刷新(connect 60s 成本)**:connect 為重量級操作,最壞 60–65s。按下「連線」立即進 loading(本地 `connectingId` 旗標,同步生效),並顯示 hint「首次連線需載入韌體,約 1 分鐘,請勿關閉頁面」承載等待體驗。connect 成功後**主動 `fetchDevice(uuid)` 重拉** `status`(**輪詢式**),使 badge 由 `connecting` → `connected`、按鈕自動切「斷線」、FlashDialog 解鎖。**不依賴 WS 推送**——device events WS(`/ws/devices/events`)雖 local agent 端已實作(connect 成功廣播 `updated`),但 backend proxy 該路由仍是 **501 stub(pending B7)**,事件到不了雲端前端。B7 打通後可改 WS 即時推。
|
||||||
|
|
||||||
|
- **timeout 鏈**:backend proxy 300s(`proxy.go` `defaultProxyRequestTimeout`)> local agent 120s(`device_handler.go` ConnectDevice)> 實際最壞 65s,層層都夠。前端無需自設 timeout;唯一需自查:`api.post` 全域 timeout 若 < 120s,需為 connect 呼叫放寬(connect 是唯一可能 > 60s 的請求)。
|
||||||
|
|
||||||
|
- **推論前置**:能進 workspace 的裝置必然已 connect(flash 前置需 connect、開啟工作區前置需 `flashedModel` 有值 = 已 flash 過),故 camera pipeline 拿到的必為 connected driver,**推論前端無需額外 connect gate**。camera_handler `IsConnected()` 早擋為 nice-to-have(動 local agent、非本次範圍)。
|
||||||
|
|
||||||
### 10.6 local-tool-only 元件:雛形隱藏(Q6 裁決)
|
### 10.6 local-tool-only 元件:雛形隱藏(Q6 裁決)
|
||||||
|
|
||||||
|
|||||||
@ -217,3 +217,143 @@ describe("DeviceDetailClient serial 路由 gating(WP-C / ADR-018)", () => {
|
|||||||
expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled();
|
expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DeviceDetailClient 兩步式連線 gate(方案 A / ADR-018)", () => {
|
||||||
|
// 未連線裝置基礎(在線 + 有 serial,但 driver status = detected)。
|
||||||
|
const detectedDevice: Device = {
|
||||||
|
...onlineDevice,
|
||||||
|
status: "detected",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("未連線(detected):顯示「連線」按鈕、flash 觸發鈕 disabled", () => {
|
||||||
|
useDeviceStore.setState({ selectedDevice: detectedDevice });
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
const connectBtn = screen.getByTestId("device-connect-btn");
|
||||||
|
expect(connectBtn).toBeEnabled();
|
||||||
|
expect(connectBtn).toHaveTextContent("連線");
|
||||||
|
// 兩步式:driver 沒 connect → flash 入口 disable(避免按了才收 400)。
|
||||||
|
expect(screen.getByTestId("flash-model-trigger")).toBeDisabled();
|
||||||
|
// 未連線不顯示斷線按鈕。
|
||||||
|
expect(screen.queryByTestId("device-disconnect-btn")).not.toBeInTheDocument();
|
||||||
|
// driver 狀態呈現。
|
||||||
|
expect(screen.getByTestId("device-driver-status")).toHaveTextContent("已偵測");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已連線(connected):顯示「斷線」按鈕、flash 觸發鈕 enabled", () => {
|
||||||
|
// onlineDevice.status === "connected"
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
const disconnectBtn = screen.getByTestId("device-disconnect-btn");
|
||||||
|
expect(disconnectBtn).toBeEnabled();
|
||||||
|
expect(disconnectBtn).toHaveTextContent("斷線");
|
||||||
|
// 已連線 → flash 入口可用。
|
||||||
|
expect(screen.getByTestId("flash-model-trigger")).toBeEnabled();
|
||||||
|
expect(screen.queryByTestId("device-connect-btn")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("device-driver-status")).toHaveTextContent("已連線");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("點「連線」→ 呼叫 connectDevice(serial) → fetchDevice(id) → success toast", async () => {
|
||||||
|
useDeviceStore.setState({ selectedDevice: detectedDevice });
|
||||||
|
const connectSpy = vi
|
||||||
|
.spyOn(useDeviceStore.getState(), "connectDevice")
|
||||||
|
.mockResolvedValue(true);
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(useDeviceStore.getState(), "fetchDevice")
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("device-connect-btn"));
|
||||||
|
|
||||||
|
// serial 路由:connect 帶 serialNumber(非 UUID)。
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(connectSpy).toHaveBeenCalledWith("KN00123456"),
|
||||||
|
);
|
||||||
|
// C4:connect 後用 UUID 重拉 status。
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith("dev-1");
|
||||||
|
expect(toast.success).toHaveBeenCalledOnce();
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("連線失敗 → toast.error(帶 description),不刷新成功文案", async () => {
|
||||||
|
useDeviceStore.setState({ selectedDevice: detectedDevice });
|
||||||
|
vi.spyOn(useDeviceStore.getState(), "connectDevice").mockResolvedValue(false);
|
||||||
|
vi.spyOn(useDeviceStore.getState(), "fetchDevice").mockResolvedValue(undefined);
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("device-connect-btn"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||||
|
const call = (toast.error as Mock).mock.calls[0];
|
||||||
|
expect(call[1].description).toBe("請確認裝置已插上且 local agent 在線");
|
||||||
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("連線中(connectingId === serial):連線按鈕 disabled + loading,且顯示 60s hint", () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: detectedDevice,
|
||||||
|
connectingId: "KN00123456",
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
const connectBtn = screen.getByTestId("device-connect-btn");
|
||||||
|
expect(connectBtn).toBeDisabled();
|
||||||
|
expect(connectBtn).toHaveTextContent("連線中…");
|
||||||
|
// 60s UX 明確 hint。
|
||||||
|
expect(screen.getByTestId("device-connecting-hint")).toHaveTextContent(
|
||||||
|
"約 1 分鐘",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("後端上報 connecting(無本地旗標):也視為連線中(loading + hint)", () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: { ...detectedDevice, status: "connecting" },
|
||||||
|
connectingId: null,
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
expect(screen.getByTestId("device-connect-btn")).toBeDisabled();
|
||||||
|
expect(screen.getByTestId("device-connecting-hint")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("device-driver-status")).toHaveTextContent("連線中");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("點「斷線」→ 呼叫 disconnectDevice(serial) → fetchDevice(id) → toast", async () => {
|
||||||
|
const disconnectSpy = vi
|
||||||
|
.spyOn(useDeviceStore.getState(), "disconnectDevice")
|
||||||
|
.mockResolvedValue(true);
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(useDeviceStore.getState(), "fetchDevice")
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("device-disconnect-btn"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(disconnectSpy).toHaveBeenCalledWith("KN00123456"),
|
||||||
|
);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith("dev-1");
|
||||||
|
expect(toast.success).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("離線(remoteStatus=offline):不顯示連線/斷線按鈕(操作區整體降級)", () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: { ...detectedDevice, remoteStatus: "offline" },
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("device-connect-btn")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("device-disconnect-btn"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flashing / inferencing 也視為已連線(斷線按鈕 + flash 可用)", () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: { ...onlineDevice, status: "inferencing" },
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
expect(screen.getByTestId("device-disconnect-btn")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("flash-model-trigger")).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -65,10 +65,46 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
const unpairDevice = useDeviceStore((s) => s.unpairDevice);
|
const unpairDevice = useDeviceStore((s) => s.unpairDevice);
|
||||||
const isUnpairing = useDeviceStore((s) => s.unpairingId === id);
|
const isUnpairing = useDeviceStore((s) => s.unpairingId === id);
|
||||||
|
|
||||||
|
// 兩步式連線(方案 A / ADR-018 serial 路由):connect/disconnect action + 進行中旗標。
|
||||||
|
const connectDevice = useDeviceStore((s) => s.connectDevice);
|
||||||
|
const disconnectDevice = useDeviceStore((s) => s.disconnectDevice);
|
||||||
|
// connectingId / disconnectingId 存的是 serialNumber(serial 路由);用當前裝置 serial 比對。
|
||||||
|
const serial = selectedDevice?.serialNumber ?? "";
|
||||||
|
const isConnectPending = useDeviceStore(
|
||||||
|
(s) => !!serial && s.connectingId === serial,
|
||||||
|
);
|
||||||
|
const isDisconnectPending = useDeviceStore(
|
||||||
|
(s) => !!serial && s.disconnectingId === serial,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) void fetchDevice(id);
|
if (id) void fetchDevice(id);
|
||||||
}, [id, fetchDevice]);
|
}, [id, fetchDevice]);
|
||||||
|
|
||||||
|
async function handleConnect() {
|
||||||
|
// gate 已保證 hasSerial;serialNumber 必有值。
|
||||||
|
const ok = await connectDevice(selectedDevice!.serialNumber!);
|
||||||
|
if (ok) {
|
||||||
|
// C4:connect 後唯一可靠刷新路徑 — 用 UUID 重拉 status(WS 推送未通)。
|
||||||
|
await fetchDevice(id);
|
||||||
|
toast.success(t("devices.connect.toast.success"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("devices.connect.toast.failed"), {
|
||||||
|
description: t("devices.connect.toast.failedDesc"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisconnect() {
|
||||||
|
const ok = await disconnectDevice(selectedDevice!.serialNumber!);
|
||||||
|
if (ok) {
|
||||||
|
await fetchDevice(id);
|
||||||
|
toast.success(t("devices.connect.toast.disconnected"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("devices.connect.toast.disconnectFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRemove() {
|
async function handleRemove() {
|
||||||
const result = await unpairDevice(id);
|
const result = await unpairDevice(id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
@ -125,6 +161,18 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
// 詳情頁本身(fetchDevice / unpair)維持 UUID,不受 serial 影響。
|
// 詳情頁本身(fetchDevice / unpair)維持 UUID,不受 serial 影響。
|
||||||
const hasSerial = !!selectedDevice.serialNumber;
|
const hasSerial = !!selectedDevice.serialNumber;
|
||||||
|
|
||||||
|
// driver(USB)連線狀態 — 方案 A 兩步式的第一步 gate(C1:status 已 normalize)。
|
||||||
|
const usbStatus = selectedDevice.status;
|
||||||
|
// connected / flashing / inferencing 三態都代表 driver 已 Connect()、可載入模型 / 推論。
|
||||||
|
const isDriverConnected =
|
||||||
|
usbStatus === "connected" ||
|
||||||
|
usbStatus === "flashing" ||
|
||||||
|
usbStatus === "inferencing";
|
||||||
|
// 後端上報的 connecting(僅在有 fetchDevice 拿到時才有);與本地 isConnectPending 併判。
|
||||||
|
const isConnecting = usbStatus === "connecting";
|
||||||
|
// loading 判定:以本地旗標為主(一定會亮),後端 connecting 補「別分頁觸發」場景。
|
||||||
|
const isConnectInProgress = isConnectPending || isConnecting;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
|
<div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
|
||||||
<Link href="/devices">
|
<Link href="/devices">
|
||||||
@ -168,12 +216,55 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* flash(載入模型):裝置在線才可 flash;離線時 disable 觸發鈕。
|
{/* 連線 / 斷線(兩步式第一步;driver-level connect,走 serial)。
|
||||||
|
前提 isOnline && hasSerial(tunnel 在線且可路由)才顯示。
|
||||||
|
未連線 → 「連線」;連線中 → loading;已連線 → 「斷線」。
|
||||||
|
connect 是 flash 的前置,故排在 FlashDialog 之前(視覺順序:連線 → 載入模型 → 工作區)。 */}
|
||||||
|
{isOnline && hasSerial &&
|
||||||
|
(isDriverConnected ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
disabled={isDisconnectPending}
|
||||||
|
data-testid="device-disconnect-btn"
|
||||||
|
>
|
||||||
|
{isDisconnectPending ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" label={t("devices.connect.disconnecting")} />
|
||||||
|
{t("devices.connect.disconnecting")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("devices.connect.disconnect")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={isConnectInProgress}
|
||||||
|
data-testid="device-connect-btn"
|
||||||
|
>
|
||||||
|
{isConnectInProgress ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" label={t("devices.connect.connecting")} />
|
||||||
|
{t("devices.connect.connecting")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("devices.connect.action")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* flash(載入模型):裝置在線 + driver 已連線才可 flash;否則 disable 觸發鈕。
|
||||||
|
driver 沒 connect 時 flash 會被 local agent 擋成 400(device not connected),
|
||||||
|
提早 disable 避免使用者按了才收 400(診斷原始 bug)。
|
||||||
WP-C(ADR-018):flash 走 serial 路由,serial 為空時同樣 disable + tooltip。
|
WP-C(ADR-018):flash 走 serial 路由,serial 為空時同樣 disable + tooltip。
|
||||||
flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方
|
flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方
|
||||||
「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */}
|
「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */}
|
||||||
{hasSerial ? (
|
{hasSerial ? (
|
||||||
<FlashDialog deviceId={selectedDevice.id} disabled={!isOnline} />
|
<FlashDialog
|
||||||
|
deviceId={selectedDevice.id}
|
||||||
|
disabled={!isOnline || !isDriverConnected}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@ -271,6 +362,17 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 60s connect UX 關鍵:loading 期間顯示明確 hint(不只按鈕內的「連線中…」),
|
||||||
|
告知使用者首次連線需載入韌體、約 1 分鐘、勿關頁面,避免乾等以為卡死。 */}
|
||||||
|
{isOnline && hasSerial && isConnectInProgress && (
|
||||||
|
<p
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
data-testid="device-connecting-hint"
|
||||||
|
>
|
||||||
|
{t("devices.connect.connectingHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@ -297,6 +399,17 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* driver(USB)連線狀態呈現(C5 / 方案 A):讓使用者看得到 device 是
|
||||||
|
detected / connecting / connected…,兩步式 gate 的狀態依據。
|
||||||
|
文案複用現成 devices.status.*(七值皆有對應 key)。 */}
|
||||||
|
<InfoRow
|
||||||
|
label={t("devices.connect.driverStatusLabel")}
|
||||||
|
value={
|
||||||
|
<span data-testid="device-driver-status">
|
||||||
|
{t(`devices.status.${usbStatus}`)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} />
|
<InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} />
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label={t("devices.detail.firmware")}
|
label={t("devices.detail.firmware")}
|
||||||
|
|||||||
@ -196,6 +196,23 @@ export const en: Dictionary = {
|
|||||||
"devices.flash.flashComplete": "Model loaded successfully",
|
"devices.flash.flashComplete": "Model loaded successfully",
|
||||||
"devices.flash.flashFailed": "Failed to load model",
|
"devices.flash.flashFailed": "Failed to load model",
|
||||||
|
|
||||||
|
// ── Devices: connect (driver-level connect, ADR-018 serial routing / two-step flow A) ──
|
||||||
|
"devices.connect.action": "Connect",
|
||||||
|
"devices.connect.connecting": "Connecting…",
|
||||||
|
"devices.connect.connectingHint":
|
||||||
|
"First connect loads firmware (~1 min). Please keep this page open.",
|
||||||
|
"devices.connect.disconnect": "Disconnect",
|
||||||
|
"devices.connect.disconnecting": "Disconnecting…",
|
||||||
|
"devices.connect.needConnectHint": "Connect the device first to flash a model",
|
||||||
|
"devices.connect.driverStatusLabel": "Connection",
|
||||||
|
"devices.connect.toast.success": "Device connected",
|
||||||
|
"devices.connect.toast.failed": "Connect failed",
|
||||||
|
"devices.connect.toast.failedDesc":
|
||||||
|
"Make sure the device is plugged in and the local agent is online.",
|
||||||
|
"devices.connect.toast.timeoutDesc": "Connect timed out. Please try again.",
|
||||||
|
"devices.connect.toast.disconnected": "Device disconnected",
|
||||||
|
"devices.connect.toast.disconnectFailed": "Disconnect failed",
|
||||||
|
|
||||||
// ── Remote Device Badge ──
|
// ── Remote Device Badge ──
|
||||||
"remote.status.online": "Online",
|
"remote.status.online": "Online",
|
||||||
"remote.status.offline": "Offline",
|
"remote.status.offline": "Offline",
|
||||||
|
|||||||
@ -197,6 +197,22 @@ export const zhHant: Dictionary = {
|
|||||||
"devices.flash.flashComplete": "模型載入成功",
|
"devices.flash.flashComplete": "模型載入成功",
|
||||||
"devices.flash.flashFailed": "模型載入失敗",
|
"devices.flash.flashFailed": "模型載入失敗",
|
||||||
|
|
||||||
|
// ── Devices: connect(driver-level 連線,ADR-018 serial 路由 / 方案 A 兩步式) ──
|
||||||
|
"devices.connect.action": "連線",
|
||||||
|
"devices.connect.connecting": "連線中…",
|
||||||
|
"devices.connect.connectingHint":
|
||||||
|
"首次連線需載入韌體,約 1 分鐘,請勿關閉頁面",
|
||||||
|
"devices.connect.disconnect": "斷線",
|
||||||
|
"devices.connect.disconnecting": "斷線中…",
|
||||||
|
"devices.connect.needConnectHint": "請先連線裝置才能載入模型",
|
||||||
|
"devices.connect.driverStatusLabel": "連線狀態",
|
||||||
|
"devices.connect.toast.success": "裝置已連線",
|
||||||
|
"devices.connect.toast.failed": "連線失敗",
|
||||||
|
"devices.connect.toast.failedDesc": "請確認裝置已插上且 local agent 在線",
|
||||||
|
"devices.connect.toast.timeoutDesc": "連線逾時,請重試",
|
||||||
|
"devices.connect.toast.disconnected": "裝置已斷線",
|
||||||
|
"devices.connect.toast.disconnectFailed": "斷線失敗",
|
||||||
|
|
||||||
// ── Remote Device Badge(雲端 tunnel 狀態) ──
|
// ── Remote Device Badge(雲端 tunnel 狀態) ──
|
||||||
"remote.status.online": "在線",
|
"remote.status.online": "在線",
|
||||||
"remote.status.offline": "離線",
|
"remote.status.offline": "離線",
|
||||||
|
|||||||
@ -231,10 +231,20 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
|||||||
|
|
||||||
// serial 路由(ADR-018):path 帶 serialNumber(kn_number),local agent 端以
|
// serial 路由(ADR-018):path 帶 serialNumber(kn_number),local agent 端以
|
||||||
// serialToLocalID 反查 sessions;帶 UUID 會 "device not found"。
|
// serialToLocalID 反查 sessions;帶 UUID 會 "device not found"。
|
||||||
|
//
|
||||||
|
// ⚠️ timeout 覆寫(方案 A 自查項):connect 是唯一可能 > 60s 的請求
|
||||||
|
// (KL520 Loader-mode reconnect + firmware reload + reboot + 二次 reconnect,
|
||||||
|
// local agent 端最壞 ~65s、ctx 120s)。api client 全域預設 timeout 只有 30s
|
||||||
|
// (api.ts DEFAULT_TIMEOUT_MS),若不放寬會在後端還在跑時就前端 abort → 誤判失敗。
|
||||||
|
// 這裡覆寫為 130s(> local agent 120s ctx,確保由後端決定成敗、前端不搶先 timeout)。
|
||||||
connectDevice: async (serialNumber) => {
|
connectDevice: async (serialNumber) => {
|
||||||
set({ connectingId: serialNumber, error: null });
|
set({ connectingId: serialNumber, error: null });
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/devices/${encodeURIComponent(serialNumber)}/connect`);
|
await api.post(
|
||||||
|
`/api/devices/${encodeURIComponent(serialNumber)}/connect`,
|
||||||
|
undefined,
|
||||||
|
{ timeoutMs: 130_000 },
|
||||||
|
);
|
||||||
set({ connectingId: null });
|
set({ connectingId: null });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user