From df9dbfee743d4c56fa7daddb09745219604929de Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:48:32 +0800 Subject: [PATCH] feat: implement fresh device discovery and update UI for device refresh --- internal/device/manager.go | 15 +++- internal/device/manager_test.go | 23 ++++++ internal/server/device_api.go | 12 +++- internal/server/device_discovery_api_test.go | 72 +++++++++++++++++++ .../components/devices/DeviceAddDialog.tsx | 10 ++- web/src/lib/i18n-en.ts | 1 + web/src/pages/DevicesPage.tsx | 15 ++-- 7 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 internal/server/device_discovery_api_test.go diff --git a/internal/device/manager.go b/internal/device/manager.go index 139c5a4..8b797ce 100644 --- a/internal/device/manager.go +++ b/internal/device/manager.go @@ -232,7 +232,20 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) { state.opMu.Unlock() } manager.resetChangedClients() - return manager.List(), nil + + // List retains previously discovered devices so configured hardware can be + // rendered as offline after it is unplugged. Discover, however, is a fresh + // physical scan and must only return devices that are present now. Returning + // the retained entries here allowed an unplugged modem to be selected and + // added again from the device discovery screen. + devices := manager.List() + present := devices[:0] + for _, entry := range devices { + if entry.Discovered { + present = append(present, entry) + } + } + return present, nil } func (manager *Manager) resetChangedClients() { diff --git a/internal/device/manager_test.go b/internal/device/manager_test.go index 7a161b2..bbcc916 100644 --- a/internal/device/manager_test.go +++ b/internal/device/manager_test.go @@ -45,6 +45,29 @@ func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) { } } +func TestManagerDiscoverReturnsOnlyCurrentlyPresentDevices(t *testing.T) { + manager, id := newStartedTestManager(t, nil) + if devices := manager.List(); len(devices) != 1 || devices[0].ID != id || !devices[0].Discovered { + t.Fatalf("initial devices = %#v", devices) + } + + manager.discoverer = staticDiscoverer{} + present, err := manager.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover after unplug: %v", err) + } + if len(present) != 0 { + t.Fatalf("present devices after unplug = %#v, want none", present) + } + + // The retained entry is still available to the configured-device dashboard, + // but is explicitly offline and cannot be offered by fresh discovery. + retained := manager.List() + if len(retained) != 1 || retained[0].ID != id || retained[0].Discovered { + t.Fatalf("retained devices after unplug = %#v", retained) + } +} + func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { client := &transcriptClient{steps: []clientStep{ { diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 22af471..16fd746 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -344,7 +344,14 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": []any{}}}) return true } - devices := s.devices.List() + // This endpoint backs the add-device dialog. Always perform a new physical + // scan instead of serving Manager.List(), which intentionally retains + // unplugged configured devices so the main device list can show them offline. + devices, err := s.devices.Discover(r.Context()) + if err != nil { + s.writeDeviceError(w, err) + return true + } configured, err := s.store.ListDevices(r.Context()) if err != nil { s.writeStoreError(w, err) @@ -352,6 +359,9 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request) } result := make([]map[string]any, 0, len(devices)) for _, entry := range devices { + if !entry.Discovered { + continue + } candidate := entry.Candidate atPorts := make([]string, 0, len(candidate.Ports)) for _, port := range candidate.Ports { diff --git a/internal/server/device_discovery_api_test.go b/internal/server/device_discovery_api_test.go new file mode 100644 index 0000000..768a214 --- /dev/null +++ b/internal/server/device_discovery_api_test.go @@ -0,0 +1,72 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "vocat/internal/device" + "vocat/internal/modem" + "vocat/internal/store" +) + +type discoverySnapshotController struct { + fakeDeviceController + entries []device.Device + discoverCalls int +} + +func (controller *discoverySnapshotController) Discover(context.Context) ([]device.Device, error) { + controller.discoverCalls++ + return append([]device.Device(nil), controller.entries...), nil +} + +func TestDiscoveredDevicesPerformsFreshScanAndOmitsAbsentEntries(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + controller := &discoverySnapshotController{ + fakeDeviceController: fakeDeviceController{entry: device.Device{ + ID: "stale-device", Discovered: false, + Candidate: modem.Candidate{ID: "stale-device", USBPath: "1-1"}, + }}, + entries: []device.Device{ + { + ID: "current-device", Discovered: true, + Candidate: modem.Candidate{ID: "current-device", USBPath: "2-1"}, + }, + { + ID: "absent-device", Discovered: false, + Candidate: modem.Candidate{ID: "absent-device", USBPath: "3-1"}, + }, + }, + } + server := &Server{ + store: database, logger: regionTestLogger(), + maxRequestBodyBytes: 4096, devices: controller, + } + request := httptest.NewRequest(http.MethodGet, "/api/devices/discovered", nil) + recorder := httptest.NewRecorder() + + if !server.handleDiscoveredDevices(recorder, request) { + t.Fatal("handleDiscoveredDevices returned false") + } + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + if controller.discoverCalls != 1 { + t.Fatalf("Discover calls = %d, want 1", controller.discoverCalls) + } + body := recorder.Body.String() + if !strings.Contains(body, "current-device") { + t.Fatalf("response omits current device: %s", body) + } + if strings.Contains(body, "stale-device") || strings.Contains(body, "absent-device") { + t.Fatalf("response contains an absent device: %s", body) + } +} diff --git a/web/src/components/devices/DeviceAddDialog.tsx b/web/src/components/devices/DeviceAddDialog.tsx index f6d40b8..5048c50 100644 --- a/web/src/components/devices/DeviceAddDialog.tsx +++ b/web/src/components/devices/DeviceAddDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, type ReactNode } from "react"; -import { SaveRegular } from "@fluentui/react-icons"; +import { ArrowSyncRegular, SaveRegular } from "@fluentui/react-icons"; import { cx } from "../../lib/utils"; import { Button, Input, Modal, Select, Spinner, Tag } from "../ui"; import { isQmiControl } from "./shared"; @@ -17,6 +17,7 @@ export interface DeviceAddDialogProps { addConfig: AddDeviceForm; addSaving: boolean; onClose: () => void; + onRefresh: () => void; onSelectDevice: (d: DiscoveredDevice) => void; onConfigChange: (next: AddDeviceForm) => void; onSave: () => void; @@ -85,7 +86,12 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) { } > -