diff --git a/internal/server/automatic_tasks.go b/internal/server/automatic_tasks.go index 807fcc7..d721f68 100644 --- a/internal/server/automatic_tasks.go +++ b/internal/server/automatic_tasks.go @@ -456,6 +456,10 @@ func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request, s.handleAutomaticTasks(w, r) return true } + if len(segments) == 2 && segments[1] == "runs" { + s.handleAutomaticTaskRuns(w, r) + return true + } id, err := strconv.ParseInt(segments[1], 10, 64) if err != nil || id <= 0 { writeError(w, http.StatusBadRequest, "invalid_task_id", "automatic task ID is invalid") @@ -481,12 +485,7 @@ func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) { s.writeStoreError(w, err) return } - runs, err := s.store.ListAutomaticTaskRuns(r.Context(), 100) - if err != nil { - s.writeStoreError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks, "runs": runs}}) + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}}) case http.MethodPost: task, err := s.decodeAutomaticTask(r, 0) if err != nil { @@ -531,6 +530,21 @@ func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id } } +func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + query := r.URL.Query() + limit, _ := strconv.Atoi(query.Get("limit")) + offset, _ := strconv.Atoi(query.Get("offset")) + runs, total, err := s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset) + if err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"runs": runs, "total": total}}) +} + func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Request, id int64) { if !requireMethod(w, r, http.MethodPost) { return diff --git a/internal/server/proxy_api.go b/internal/server/proxy_api.go index 8d55b24..e202c3f 100644 --- a/internal/server/proxy_api.go +++ b/internal/server/proxy_api.go @@ -26,8 +26,8 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath writeJSON(w, http.StatusOK, map[string]any{"data": proxyCountries}) case "upstream-proxy-country-rules": s.handleCountryRules(w, r) - case "upstream-proxy-device-bindings": - s.handleDeviceProxyBindings(w, r) + case "upstream-proxy-profile-bindings": + s.handleProfileProxyBindings(w, r) default: segments := splitAPIPath(cleanPath) switch { @@ -40,8 +40,6 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath s.handleUpstreamProbe(w, r, segments[1]) case len(segments) == 2 && segments[0] == "upstream-proxy-country-rules": s.handleCountryRule(w, r, segments[1]) - case len(segments) == 2 && segments[0] == "upstream-proxy-device-bindings": - s.handleDeviceProxyBinding(w, r, segments[1]) default: return false } @@ -114,7 +112,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id } for _, binding := range bindings { if binding.UpstreamProxyID == id { - s.requestProxyRouteReconnect(binding.DeviceID) + s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID) } } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}}) @@ -124,36 +122,32 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id } } -func (s *Server) handleDeviceProxyBindings(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodGet) { - return - } - values, err := s.store.ListDeviceProxyBindings(r.Context()) - if err != nil { - s.writeStoreError(w, err) - return - } - result := make([]map[string]any, 0, len(values)) - for _, value := range values { - result = append(result, deviceProxyBindingResponse(value)) - } - writeJSON(w, http.StatusOK, map[string]any{"data": result}) +type profileProxyBindingPayload struct { + DeviceID string `json:"device_id"` + ICCID string `json:"iccid"` + ProfileName string `json:"profile_name"` + // Accepted for compatibility with the first profile-picker bundle, which + // sent the read-only display state together with the writable identity. + StateText string `json:"state_text,omitempty"` } -func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request, deviceID string) { - deviceID = strings.TrimSpace(deviceID) - if !validDeviceID(deviceID) { - writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters") - return - } - if _, err := s.store.Device(r.Context(), deviceID); err != nil { - s.writeStoreError(w, err) - return - } +func (s *Server) handleProfileProxyBindings(w http.ResponseWriter, r *http.Request) { switch r.Method { - case http.MethodPut: + case http.MethodGet: + values, err := s.store.ListDeviceProxyBindings(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + result := make([]map[string]any, 0, len(values)) + for _, value := range values { + result = append(result, deviceProxyBindingResponse(value)) + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) + case http.MethodPost: var request struct { - UpstreamProxyID string `json:"upstream_proxy_id"` + UpstreamProxyID string `json:"upstream_proxy_id"` + Bindings []profileProxyBindingPayload `json:"bindings"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) @@ -166,44 +160,114 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request return } if !upstream.Enabled { - writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a device") + writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a profile") return } - // Once bound, a device may not be silently rebinded to a different - // upstream proxy. Force the caller to DELETE first so the change is - // intentional. Re-binding the same upstream stays idempotent. - if existing, err := s.store.DeviceProxyBinding(r.Context(), deviceID); err == nil && existing.UpstreamProxyID != upstream.ID { - writeError(w, http.StatusConflict, "device_already_bound", "device is already bound to another upstream proxy; delete the binding first") - return - } else if err != nil && !errors.Is(err, store.ErrNotFound) { - s.writeStoreError(w, err) + if len(request.Bindings) == 0 || len(request.Bindings) > 200 { + writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles") return } - value := store.DeviceProxyBinding{DeviceID: deviceID, UpstreamProxyID: upstream.ID} - if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil { - s.writeStoreError(w, err) - return + values := make([]store.DeviceProxyBinding, 0, len(request.Bindings)) + seen := make(map[string]struct{}, len(request.Bindings)) + for _, item := range request.Bindings { + deviceID := strings.TrimSpace(item.DeviceID) + iccid := strings.TrimSpace(item.ICCID) + if !validDeviceID(deviceID) { + writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters") + return + } + if !validProfileICCID(iccid) { + writeError(w, http.StatusBadRequest, "invalid_iccid", "profile ICCID must contain 18 to 22 digits") + return + } + if _, duplicate := seen[iccid]; duplicate { + writeError(w, http.StatusBadRequest, "duplicate_iccid", "the same ICCID was selected more than once") + return + } + seen[iccid] = struct{}{} + if _, err := s.store.Device(r.Context(), deviceID); err != nil { + s.writeStoreError(w, err) + return + } + if existing, err := s.store.DeviceProxyBinding(r.Context(), iccid); err == nil && existing.UpstreamProxyID != upstream.ID { + writeError(w, http.StatusConflict, "profile_already_bound", "this ICCID is already bound to another upstream proxy; delete that binding first") + return + } else if err != nil && !errors.Is(err, store.ErrNotFound) { + s.writeStoreError(w, err) + return + } + name := strings.TrimSpace(item.ProfileName) + if name == "" { + name = iccid + } + values = append(values, store.DeviceProxyBinding{DeviceID: deviceID, ICCID: iccid, ProfileName: name, UpstreamProxyID: upstream.ID}) } - reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID) - response := deviceProxyBindingResponse(value) - response["reconnect_requested"] = reconnected - if reconnectErr != nil { - response["reconnect_error"] = reconnectErr.Error() + requested := false + var reconnectErrors []string + for _, value := range values { + if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil { + s.writeStoreError(w, err) + return + } + reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(value.DeviceID, value.ICCID) + requested = requested || reconnected + if reconnectErr != nil { + reconnectErrors = append(reconnectErrors, reconnectErr.Error()) + } + } + response := map[string]any{"created": len(values), "reconnect_requested": requested} + if len(reconnectErrors) > 0 { + response["reconnect_error"] = strings.Join(reconnectErrors, "; ") } writeJSON(w, http.StatusOK, map[string]any{"data": response}) case http.MethodDelete: - if err := s.store.DeleteDeviceProxyBinding(r.Context(), deviceID); err != nil { - s.writeStoreError(w, err) + var request struct { + UpstreamProxyID string `json:"upstream_proxy_id"` + ICCIDs []string `json:"iccids"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } - reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID) - response := map[string]any{"deleted": true, "reconnect_requested": reconnected} - if reconnectErr != nil { - response["reconnect_error"] = reconnectErr.Error() + if len(request.ICCIDs) == 0 || len(request.ICCIDs) > 200 { + writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles") + return + } + requested := false + deleted := 0 + var reconnectErrors []string + for _, rawICCID := range request.ICCIDs { + iccid := strings.TrimSpace(rawICCID) + binding, err := s.store.DeviceProxyBinding(r.Context(), iccid) + if errors.Is(err, store.ErrNotFound) { + continue + } + if err != nil { + s.writeStoreError(w, err) + return + } + if strings.TrimSpace(request.UpstreamProxyID) != "" && binding.UpstreamProxyID != strings.TrimSpace(request.UpstreamProxyID) { + writeError(w, http.StatusConflict, "binding_proxy_mismatch", "selected ICCID is not bound to this upstream proxy") + return + } + if err := s.store.DeleteDeviceProxyBinding(r.Context(), iccid); err != nil { + s.writeStoreError(w, err) + return + } + deleted++ + reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID) + requested = requested || reconnected + if reconnectErr != nil { + reconnectErrors = append(reconnectErrors, reconnectErr.Error()) + } + } + response := map[string]any{"deleted": deleted, "reconnect_requested": requested} + if len(reconnectErrors) > 0 { + response["reconnect_error"] = strings.Join(reconnectErrors, "; ") } writeJSON(w, http.StatusOK, map[string]any{"data": response}) default: - w.Header().Set("Allow", "PUT, DELETE") + w.Header().Set("Allow", "GET, POST, DELETE") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } } @@ -211,7 +275,7 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request // A binding is already durable before this is called. Reconnect failures are // returned as advisory information: the chosen route will still be used on // the next VoWiFi start/reconnect. -func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) { +func (s *Server) requestProfileProxyRouteReconnect(deviceID, iccid string) (bool, error) { if s.vowifi == nil { return false, nil } @@ -222,6 +286,10 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) { if !config.VoWiFiEnabled { return false, nil } + state, stateErr := s.vowifi.State(deviceID) + if stateErr != nil || strings.TrimSpace(state.ICCID) == "" || strings.TrimSpace(state.ICCID) != strings.TrimSpace(iccid) { + return false, nil + } if _, err := s.vowifi.RequestReconnect(deviceID); err != nil { s.logger.Warn("VoWiFi proxy route saved but immediate reconnect was not started", "device_id", deviceID, "error", err) return false, err @@ -229,6 +297,19 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) { return true, nil } +func validProfileICCID(value string) bool { + value = strings.TrimSpace(value) + if len(value) < 18 || len(value) > 22 { + return false + } + for _, digit := range value { + if digit < '0' || digit > '9' { + return false + } + } + return true +} + func (s *Server) saveAndProbeUpstream( w http.ResponseWriter, r *http.Request, @@ -258,7 +339,7 @@ func (s *Server) saveAndProbeUpstream( } for _, binding := range bindings { if binding.UpstreamProxyID == saved.ID { - s.requestProxyRouteReconnect(binding.DeviceID) + s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID) } } probe, probeErr := localproxy.ProbeSOCKS5( @@ -449,6 +530,8 @@ func countryRuleResponse(value store.CountryRule) map[string]any { func deviceProxyBindingResponse(value store.DeviceProxyBinding) map[string]any { return map[string]any{ "device_id": value.DeviceID, + "iccid": value.ICCID, + "profile_name": value.ProfileName, "upstream_proxy_id": value.UpstreamProxyID, } } diff --git a/internal/server/proxy_binding_test.go b/internal/server/proxy_binding_test.go index 65762b7..3dd42e4 100644 --- a/internal/server/proxy_binding_test.go +++ b/internal/server/proxy_binding_test.go @@ -10,120 +10,86 @@ import ( "testing" "vocat/internal/store" + "vocat/internal/vowifi" ) -func TestDeviceProxyBindingPersistsAndReconnectsEnabledVoWiFi(t *testing.T) { +const testProfileICCID = "89441000400128014257" + +func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) { + t.Helper() database, err := store.Open(context.Background(), ":memory:") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) - if err := database.UpsertDevice(context.Background(), store.Device{ - ID: "ec20", Name: "EC20", VoWiFiEnabled: true, - }); err != nil { + if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20", VoWiFiEnabled: true}); err != nil { t.Fatal(err) } - if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ - ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true, - }); err != nil { - t.Fatal(err) - } - controller := &fakeVoWiFiController{} - server := &Server{ - store: database, - vowifi: controller, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - maxRequestBodyBytes: 4096, - } - - request := httptest.NewRequest( - http.MethodPut, - "/api/upstream-proxy-device-bindings/ec20", - bytes.NewBufferString(`{"upstream_proxy_id":"route-1"}`), - ) - request.Header.Set("Content-Type", "application/json") - response := httptest.NewRecorder() - server.handleDeviceProxyBinding(response, request, "ec20") - if response.Code != http.StatusOK { - t.Fatalf("PUT status = %d, body = %s", response.Code, response.Body.String()) - } - binding, err := database.DeviceProxyBinding(context.Background(), "ec20") - if err != nil || binding.UpstreamProxyID != "route-1" { - t.Fatalf("binding = %+v, %v", binding, err) - } - if controller.reconnects != 1 { - t.Fatalf("reconnects = %d, want 1", controller.reconnects) - } - - request = httptest.NewRequest(http.MethodDelete, "/api/upstream-proxy-device-bindings/ec20", nil) - response = httptest.NewRecorder() - server.handleDeviceProxyBinding(response, request, "ec20") - if response.Code != http.StatusOK { - t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String()) - } - if _, err := database.DeviceProxyBinding(context.Background(), "ec20"); err != store.ErrNotFound { - t.Fatalf("binding after delete error = %v, want ErrNotFound", err) - } - if controller.reconnects != 2 { - t.Fatalf("reconnects = %d, want 2", controller.reconnects) - } -} - -func TestDeviceProxyBindingRejectsRebindToDifferentUpstream(t *testing.T) { - database, err := store.Open(context.Background(), ":memory:") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = database.Close() }) - if err := database.UpsertDevice(context.Background(), store.Device{ - ID: "ec20", Name: "EC20", VoWiFiEnabled: true, - }); err != nil { - t.Fatal(err) - } - for _, up := range []store.UpstreamProxy{ + for _, upstream := range []store.UpstreamProxy{ {ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true}, {ID: "route-2", Name: "Route 2", Addr: "127.0.0.1:1081", Enabled: true}, } { - if err := database.UpsertUpstreamProxy(context.Background(), up); err != nil { + if err := database.UpsertUpstreamProxy(context.Background(), upstream); err != nil { t.Fatal(err) } } - server := &Server{ - store: database, - vowifi: &fakeVoWiFiController{}, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - maxRequestBodyBytes: 4096, + controller := &fakeVoWiFiController{state: vowifi.State{DeviceID: "ec20", ICCID: testProfileICCID, Enabled: true}} + return &Server{store: database, vowifi: controller, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), maxRequestBodyBytes: 16 << 10}, database, controller +} + +func profileBindingRequest(t *testing.T, server *Server, method, body string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, "/api/upstream-proxy-profile-bindings", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.handleProfileProxyBindings(response, request) + return response +} + +func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T) { + server, database, controller := newProfileBindingTestServer(t) + response := profileBindingRequest(t, server, http.MethodPost, `{ + "upstream_proxy_id":"route-1", + "bindings":[ + {"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"}, + {"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"} + ] + }`) + if response.Code != http.StatusOK { + t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String()) + } + binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID) + if err != nil || binding.UpstreamProxyID != "route-1" || binding.ProfileName != "Vodafone UK" { + t.Fatalf("binding = %+v, %v", binding, err) + } + if controller.reconnects != 1 { + t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects) } - // First bind to route-1 succeeds. - put := func(proxyID string) *httptest.ResponseRecorder { - req := httptest.NewRequest( - http.MethodPut, - "/api/upstream-proxy-device-bindings/ec20", - bytes.NewBufferString(`{"upstream_proxy_id":"`+proxyID+`"}`), - ) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - server.handleDeviceProxyBinding(rec, req, "ec20") - return rec + response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`) + if response.Code != http.StatusOK { + t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String()) } - if rec := put("route-1"); rec.Code != http.StatusOK { - t.Fatalf("initial bind status = %d, body = %s", rec.Code, rec.Body.String()) + if _, err := database.DeviceProxyBinding(context.Background(), testProfileICCID); err != store.ErrNotFound { + t.Fatalf("binding after delete error = %v, want ErrNotFound", err) } - - // Rebind to a different upstream must be rejected with 409. - rec := put("route-2") - if rec.Code != http.StatusConflict { - t.Fatalf("rebind status = %d, want 409, body = %s", rec.Code, rec.Body.String()) - } - binding, err := database.DeviceProxyBinding(context.Background(), "ec20") - if err != nil || binding.UpstreamProxyID != "route-1" { - t.Fatalf("binding after rejected rebind = %+v, %v (want route-1 unchanged)", binding, err) - } - - // Re-binding the SAME upstream stays idempotent (no 409). - if rec := put("route-1"); rec.Code != http.StatusOK { - t.Fatalf("idempotent rebind status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + if controller.reconnects != 2 { + t.Fatalf("reconnects after delete = %d, want 2", controller.reconnects) } } +func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) { + server, database, _ := newProfileBindingTestServer(t) + first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`) + if first.Code != http.StatusOK { + t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String()) + } + second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`) + if second.Code != http.StatusConflict { + t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String()) + } + binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID) + if err != nil || binding.UpstreamProxyID != "route-1" { + t.Fatalf("binding after rejected rebind = %+v, %v", binding, err) + } +} diff --git a/internal/store/automatic_tasks.go b/internal/store/automatic_tasks.go index 4aeeaa0..391197a 100644 --- a/internal/store/automatic_tasks.go +++ b/internal/store/automatic_tasks.go @@ -179,16 +179,51 @@ func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun return err } +const automaticTaskRunSelect = ` + SELECT id, task_id, device_id, scheduled_at, started_at, finished_at, + status, attempts, output, error, created_at, updated_at + FROM automatic_task_runs` + func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]AutomaticTaskRun, error) { if limit <= 0 || limit > 500 { limit = 100 } - rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, device_id, scheduled_at, - started_at, finished_at, status, attempts, output, error, created_at, updated_at - FROM automatic_task_runs ORDER BY id DESC LIMIT ?`, limit) + rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ?`, limit) if err != nil { return nil, err } + return scanAutomaticTaskRuns(rows) +} + +// ListAutomaticTaskRunsPaginated returns one page of runs (newest first) plus +// the total run count, so the UI can page through the full history instead of +// a fixed recent window. +func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + total := 0 + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count automatic task runs: %w", err) + } + rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, 0, err + } + runs, err := scanAutomaticTaskRuns(rows) + if err != nil { + return nil, 0, err + } + return runs, total, nil +} + +func scanAutomaticTaskRuns(rows *sql.Rows) ([]AutomaticTaskRun, error) { defer rows.Close() var result []AutomaticTaskRun for rows.Next() { diff --git a/internal/store/automatic_tasks_test.go b/internal/store/automatic_tasks_test.go index 14b5e0e..ed30b96 100644 --- a/internal/store/automatic_tasks_test.go +++ b/internal/store/automatic_tasks_test.go @@ -70,3 +70,51 @@ func TestDeletingAutomaticTaskRemovesRunHistory(t *testing.T) { t.Fatalf("orphan runs = %+v, %v", runs, err) } } + +func TestListAutomaticTaskRunsPaginated(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-runs-page.db")) + mustSaveDevice(t, database, "ec20", "EC20") + task, err := database.SaveAutomaticTask(ctx, AutomaticTask{ + Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one", + TaskType: "call", Environment: "cellular", IntervalDays: 1, + StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`), + NextRunAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + for index := 0; index < 5; index++ { + if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil { + t.Fatal(err) + } + } + + first, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 0) + if err != nil { + t.Fatal(err) + } + if total != 5 || len(first) != 2 { + t.Fatalf("first page: total = %d, runs = %+v", total, first) + } + if first[0].ID <= first[1].ID { + t.Fatalf("runs not newest-first: %+v", first) + } + + last, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 4) + if err != nil { + t.Fatal(err) + } + if total != 5 || len(last) != 1 { + t.Fatalf("last page: total = %d, runs = %+v", total, last) + } + + // Out-of-range paging inputs are clamped to defaults, not errors. + all, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 0, -5) + if err != nil { + t.Fatal(err) + } + if total != 5 || len(all) != 5 { + t.Fatalf("clamped page: total = %d, runs = %+v", total, all) + } +} diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index a80c3cc..b54e6b2 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -106,6 +106,48 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) { } } +func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "profile-proxy-binding.db") + raw, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + for version := 1; version <= 11; version++ { + for _, statement := range migrationStatements(version) { + if _, err := raw.ExecContext(ctx, statement); err != nil { + t.Fatalf("create v%d schema: %v", version, err) + } + } + } + if _, err := raw.ExecContext(ctx, ` + INSERT INTO devices (id, name, created_at, updated_at) VALUES + ('known', 'Known', 100, 100), ('unknown', 'Unknown', 100, 100); + INSERT INTO upstream_proxies (id, name, addr, created_at, updated_at) + VALUES ('route', 'Route', '127.0.0.1:1080', 100, 100); + INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES + ('known', 'route', 100, 100), ('unknown', 'route', 100, 100); + INSERT INTO vowifi_runtime (device_id, iccid, updated_at) + VALUES ('known', '89441000400128014257', 100); + PRAGMA user_version = 11; + `); err != nil { + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + + database := openTestStore(t, path) + binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257") + if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" { + t.Fatalf("migrated binding = %+v, %v", binding, err) + } + bindings, err := database.ListDeviceProxyBindings(ctx) + if err != nil || len(bindings) != 1 { + t.Fatalf("migrated bindings = %+v, %v; unknown ICCID binding must be dropped", bindings, err) + } +} + func TestMigration9NormalizesVoWiFiAirplanePolicy(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "rf-safe-policy.db") @@ -604,12 +646,12 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) { t.Fatalf("CountryRule() = %+v, %v", rule, err) } if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{ - DeviceID: "ec20-1", UpstreamProxyID: "up-1", + DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1", }); err != nil { t.Fatal(err) } - binding, err := database.DeviceProxyBinding(ctx, "ec20-1") - if err != nil || binding.UpstreamProxyID != "up-1" { + binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257") + if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" { t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err) } if err := database.DeleteUpstreamProxy(ctx, "up-1"); err != nil { @@ -618,7 +660,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) { if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) { t.Fatalf("country rule should cascade with upstream deletion, got %v", err) } - if _, err := database.DeviceProxyBinding(ctx, "ec20-1"); !errors.Is(err, ErrNotFound) { + if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) { t.Fatalf("device binding should cascade with upstream deletion, got %v", err) } } diff --git a/internal/store/migrations.go b/internal/store/migrations.go index c38a0b2..9a6a35d 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -196,6 +196,37 @@ func migrationStatements(version int) []string { `CREATE INDEX IF NOT EXISTS sms_send_attempts_created_idx ON sms_send_attempts(created_at, id)`, } + case 12: + return []string{ + `ALTER TABLE device_proxy_bindings RENAME TO device_proxy_bindings_v11`, + `CREATE TABLE device_proxy_bindings ( + iccid TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + profile_name TEXT NOT NULL DEFAULT '', + upstream_proxy_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE, + FOREIGN KEY (upstream_proxy_id) REFERENCES upstream_proxies(id) ON DELETE CASCADE + )`, + // A legacy device-wide binding is safe to preserve only when the + // currently observed ICCID is known. It then becomes one profile binding + // instead of leaking onto every future profile used by that device. + `INSERT OR IGNORE INTO device_proxy_bindings ( + iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at + ) + SELECT COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')), + b.device_id, '', b.upstream_proxy_id, b.created_at, b.updated_at + FROM device_proxy_bindings_v11 b + LEFT JOIN vowifi_runtime v ON v.device_id = b.device_id + LEFT JOIN device_runtime d ON d.device_id = b.device_id + WHERE COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')) IS NOT NULL`, + `DROP TABLE device_proxy_bindings_v11`, + `CREATE INDEX device_proxy_bindings_proxy_idx + ON device_proxy_bindings(upstream_proxy_id)`, + `CREATE INDEX device_proxy_bindings_device_idx + ON device_proxy_bindings(device_id, iccid)`, + } default: return nil } diff --git a/internal/store/models.go b/internal/store/models.go index eab9e6c..0e1c3f7 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -302,11 +302,12 @@ type CountryRule struct { UpdatedAt time.Time } -// DeviceProxyBinding selects the SOCKS5 upstream used by one device's whole -// VoWiFi runtime. The IKE/IPsec transport uses this route and IMS/SMS then -// travel inside that tunnel. +// DeviceProxyBinding selects the SOCKS5 upstream for exactly one eSIM profile. +// ICCID is globally unique, while one proxy may serve profiles on many devices. type DeviceProxyBinding struct { DeviceID string + ICCID string + ProfileName string UpstreamProxyID string CreatedAt time.Time UpdatedAt time.Time diff --git a/internal/store/proxy.go b/internal/store/proxy.go index 75c74d3..0d9f3ad 100644 --- a/internal/store/proxy.go +++ b/internal/store/proxy.go @@ -358,9 +358,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) { func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error { value.DeviceID = strings.TrimSpace(value.DeviceID) + value.ICCID = strings.TrimSpace(value.ICCID) + value.ProfileName = strings.TrimSpace(value.ProfileName) value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID) - if value.DeviceID == "" || value.UpstreamProxyID == "" { - return errors.New("device proxy binding requires device and upstream proxy IDs") + if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" { + return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID") } now := time.Now().UTC() createdAt := value.CreatedAt @@ -373,28 +375,30 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB } _, err := s.db.ExecContext(ctx, ` INSERT INTO device_proxy_bindings ( - device_id, upstream_proxy_id, created_at, updated_at - ) VALUES (?, ?, ?, ?) - ON CONFLICT(device_id) DO UPDATE SET + iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(iccid) DO UPDATE SET + device_id = excluded.device_id, + profile_name = excluded.profile_name, upstream_proxy_id = excluded.upstream_proxy_id, updated_at = excluded.updated_at - `, value.DeviceID, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix()) + `, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix()) if err != nil { - return fmt.Errorf("upsert proxy binding for device %q: %w", value.DeviceID, err) + return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err) } return nil } -func (s *Store) DeviceProxyBinding(ctx context.Context, deviceID string) (DeviceProxyBinding, error) { +func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) { return deviceProxyBinding(s.db.QueryRowContext( ctx, - deviceProxyBindingSelect+` WHERE device_id = ?`, - strings.TrimSpace(deviceID), + deviceProxyBindingSelect+` WHERE iccid = ?`, + strings.TrimSpace(iccid), )) } func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBinding, error) { - rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id`) + rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id, profile_name COLLATE NOCASE, iccid`) if err != nil { return nil, fmt.Errorf("list device proxy bindings: %w", err) } @@ -413,26 +417,26 @@ func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBindi return values, nil } -func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, deviceID string) error { +func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, iccid string) error { result, err := s.db.ExecContext( ctx, - `DELETE FROM device_proxy_bindings WHERE device_id = ?`, - strings.TrimSpace(deviceID), + `DELETE FROM device_proxy_bindings WHERE iccid = ?`, + strings.TrimSpace(iccid), ) if err != nil { - return fmt.Errorf("delete proxy binding for device %q: %w", deviceID, err) + return fmt.Errorf("delete proxy binding for ICCID %q: %w", iccid, err) } return requireAffected(result) } const deviceProxyBindingSelect = ` - SELECT device_id, upstream_proxy_id, created_at, updated_at + SELECT device_id, iccid, profile_name, upstream_proxy_id, created_at, updated_at FROM device_proxy_bindings` func deviceProxyBinding(row rowScanner) (DeviceProxyBinding, error) { var value DeviceProxyBinding var createdAt, updatedAt int64 - err := row.Scan(&value.DeviceID, &value.UpstreamProxyID, &createdAt, &updatedAt) + err := row.Scan(&value.DeviceID, &value.ICCID, &value.ProfileName, &value.UpstreamProxyID, &createdAt, &updatedAt) if errors.Is(err, sql.ErrNoRows) { return DeviceProxyBinding{}, ErrNotFound } diff --git a/internal/store/store.go b/internal/store/store.go index e0d9d4f..95f2295 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,7 +13,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 11 +const schemaVersion = 12 var ErrNotFound = errors.New("store: not found") diff --git a/internal/vowifi/integration/store.go b/internal/vowifi/integration/store.go index 9e2f1f7..93313d5 100644 --- a/internal/vowifi/integration/store.go +++ b/internal/vowifi/integration/store.go @@ -27,15 +27,16 @@ func (resolver ProxyResolver) Resolve( return vowifi.ProxyRoute{}, errors.New("vowifi proxy resolver: store is nil") } deviceID := strings.TrimSpace(request.DeviceID) - if deviceID == "" { + iccid := strings.TrimSpace(request.ICCID) + if deviceID == "" || iccid == "" { return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil } - binding, err := resolver.Store.DeviceProxyBinding(ctx, deviceID) + binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid) if errors.Is(err, store.ErrNotFound) { return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil } if err != nil { - return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for device %s: %w", deviceID, err) + return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err) } upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID) if err != nil { diff --git a/internal/vowifi/integration/store_test.go b/internal/vowifi/integration/store_test.go index 17fa892..f8cbad3 100644 --- a/internal/vowifi/integration/store_test.go +++ b/internal/vowifi/integration/store_test.go @@ -21,7 +21,7 @@ func testStore(t *testing.T) *store.Store { return database } -func TestProxyResolverUsesDeviceBinding(t *testing.T) { +func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) { database := testStore(t) if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil { t.Fatal(err) @@ -38,13 +38,15 @@ func TestProxyResolverUsesDeviceBinding(t *testing.T) { } if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{ DeviceID: "ec20", + ICCID: "89441000400128014257", + ProfileName: "Vodafone UK", UpstreamProxyID: "clash", }); err != nil { t.Fatal(err) } route, err := (ProxyResolver{Store: database}).Resolve( context.Background(), - vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234", HomeMNC: "15"}, + vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", HomeMNC: "15"}, ) if err != nil { t.Fatal(err) @@ -57,6 +59,26 @@ func TestProxyResolverUsesDeviceBinding(t *testing.T) { } } +func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.T) { + database := testStore(t) + if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil { + t.Fatal(err) + } + if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil { + t.Fatal(err) + } + if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil { + t.Fatal(err) + } + route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"}) + if err != nil { + t.Fatal(err) + } + if route.Mode != vowifi.ProxyModeDirect { + t.Fatalf("route = %#v, want direct for unbound ICCID", route) + } +} + func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) { database := testStore(t) if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ diff --git a/internal/vowifi/orchestrator.go b/internal/vowifi/orchestrator.go index 6b00d3c..3c29661 100644 --- a/internal/vowifi/orchestrator.go +++ b/internal/vowifi/orchestrator.go @@ -257,6 +257,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { } proxy, err := orchestrator.deps.Proxy.Resolve(setupContext, ProxyRequest{ DeviceID: orchestrator.options.DeviceID, + ICCID: strings.TrimSpace(identity.ICCID), HomeMCC: strings.TrimSpace(identity.HomeMCC), HomeMNC: strings.TrimSpace(identity.HomeMNC), CountryCode: strings.ToUpper(strings.TrimSpace(identity.HomeCountryCode)), diff --git a/internal/vowifi/types.go b/internal/vowifi/types.go index 52a1ab4..cfe09ad 100644 --- a/internal/vowifi/types.go +++ b/internal/vowifi/types.go @@ -203,6 +203,7 @@ type ProxyRoute struct { type ProxyRequest struct { DeviceID string + ICCID string HomeMCC string HomeMNC string CountryCode string diff --git a/web/src/components/proxy/DeviceBindingsDialog.tsx b/web/src/components/proxy/DeviceBindingsDialog.tsx index 95bf29d..2d712b5 100644 --- a/web/src/components/proxy/DeviceBindingsDialog.tsx +++ b/web/src/components/proxy/DeviceBindingsDialog.tsx @@ -1,6 +1,8 @@ -import { DesktopRegular, LinkRegular } from "@fluentui/react-icons"; -import type { DeviceListItem, DeviceProxyBinding, UpstreamProxy } from "../../types"; -import { Button, EmptyState, Modal, Tag } from "../ui"; +import { AddRegular, DeleteRegular } from "@fluentui/react-icons"; +import { useEffect, useMemo, useState } from "react"; +import { api, apiMessage } from "../../api"; +import type { DeviceListItem, DeviceProxyBinding, EsimOverview, ProfileProxyCandidate, UpstreamProxy } from "../../types"; +import { Button, EmptyState, Modal, Tag, message } from "../ui"; import { useI18n } from "../../lib/i18n"; export interface DeviceBindingsDialogProps { @@ -9,69 +11,152 @@ export interface DeviceBindingsDialogProps { proxies: UpstreamProxy[]; devices: DeviceListItem[]; bindings: DeviceProxyBinding[]; - busyDevice: string; - onBind: (deviceId: string) => void; - onUnbind: (deviceId: string) => void; + busy: boolean; + onAdd: (profiles: ProfileProxyCandidate[]) => void; + onDelete: (iccids: string[]) => void; onClose: () => void; } +function profileLabel(profile: { name?: string; serviceProviderName?: string; iccid: string }) { + return String(profile.name || profile.serviceProviderName || profile.iccid).trim(); +} + export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) { const { t } = useI18n(); - const { open, proxy, proxies, devices, bindings, busyDevice, onBind, onUnbind, onClose } = props; + const { open, proxy, proxies, devices, bindings, busy, onAdd, onDelete, onClose } = props; + const [adding, setAdding] = useState(false); + const [loadingProfiles, setLoadingProfiles] = useState(false); + const [candidates, setCandidates] = useState([]); + const [selected, setSelected] = useState([]); const proxyName = proxy?.name || proxy?.id || ""; - const bindingByDevice = new Map(bindings.map((item) => [item.deviceId, item])); - const proxyNameById = new Map(proxies.map((item) => [item.id, item.name || item.id])); + const deviceKey = devices.map((device) => device.id).sort().join("|"); + const current = useMemo( + () => bindings.filter((item) => item.upstreamProxyId === proxy?.id), + [bindings, proxy?.id], + ); + const bindingByICCID = useMemo(() => new Map(bindings.map((item) => [item.iccid, item])), [bindings]); + const proxyNameById = useMemo(() => new Map(proxies.map((item) => [item.id, item.name || item.id])), [proxies]); + + useEffect(() => { + if (!open) { + setAdding(false); + setSelected([]); + setCandidates([]); + } + }, [open]); + + useEffect(() => { + if (!adding || !open) return; + let active = true; + setLoadingProfiles(true); + Promise.allSettled(devices.map(async (device) => { + const data = await api(`/devices/${encodeURIComponent(device.id)}/esim`); + return (data.profiles || []).flatMap((group) => (group.profiles || []).map((profile) => ({ + deviceId: device.id, + iccid: String(profile.iccid || "").trim(), + profileName: profileLabel(profile), + stateText: profile.stateText, + }))).filter((profile) => profile.iccid); + })).then((results) => { + if (!active) return; + const unique = new Map(); + for (const result of results) { + if (result.status !== "fulfilled") continue; + for (const profile of result.value) if (!unique.has(profile.iccid)) unique.set(profile.iccid, profile); + } + setCandidates(Array.from(unique.values()).sort((a, b) => a.deviceId.localeCompare(b.deviceId) || a.profileName.localeCompare(b.profileName))); + }).catch((error) => { + if (active) message.error(apiMessage(error) || t("读取 eSIM Profile 失败")); + }).finally(() => { + if (active) setLoadingProfiles(false); + }); + return () => { active = false; }; + }, [adding, open, deviceKey, t]); + + useEffect(() => { + if (!adding || selected.length === 0) return; + if (selected.every((iccid) => bindingByICCID.get(iccid)?.upstreamProxyId === proxy?.id)) { + setAdding(false); + setSelected([]); + } + }, [adding, selected, bindingByICCID, proxy?.id]); + + useEffect(() => { + if (adding) return; + const available = new Set(current.map((item) => item.iccid)); + setSelected((items) => items.filter((iccid) => available.has(iccid))); + }, [adding, current]); + + const rows = adding ? candidates : current; + const selectable = rows.filter((row) => adding ? !bindingByICCID.has(row.iccid) : true).map((row) => row.iccid); + const allSelected = selectable.length > 0 && selectable.every((iccid) => selected.includes(iccid)); + const toggle = (iccid: string) => setSelected((values) => values.includes(iccid) ? values.filter((item) => item !== iccid) : [...values, iccid]); + const toggleAll = () => setSelected(allSelected ? [] : selectable); return ( - +
- {t("绑定后,该设备的 VoWiFi 建链和通信都会使用此 SOCKS5 代理;解绑后恢复直连。配置变更会立即尝试重连 VoWiFi。")} + {t("VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。")}
- {devices.length === 0 ? ( - - ) : ( -
- {devices.map((device) => { - const binding = bindingByDevice.get(device.id); - const boundHere = binding?.upstreamProxyId === proxy?.id; - const boundElsewhere = !!binding && !boundHere; - return ( -
-
- - - -
-
- {device.name || device.id} - {device.id} - {boundHere ? {t("已绑定")} : null} - {!device.vowifiEnabled ? {t("VoWiFi 未启用")} : null} -
-
- {boundHere - ? t("当前通过此代理通信") - : boundElsewhere - ? `${t("当前绑定")}: ${proxyNameById.get(binding.upstreamProxyId) || binding.upstreamProxyId}` - : t("当前直连")} -
-
-
- {boundHere ? ( - - ) : ( - - )} -
- ); - })} +
+
{adding ? t("从设备已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 Profile")}`}
+
+ {adding ? ( + + ) : null} + {adding ? ( + + ) : ( + <> + + + + )}
- )} +
+ +
+ + + + + + + + {adding ? : null} + + + + {rows.map((row) => { + const existing = bindingByICCID.get(row.iccid); + const unavailable = adding && !!existing; + return ( + + + + + + {adding ? ( + + ) : null} + + ); + })} + +
{t("设备 ID")}ICCID{t("Profile 名称")}{t("状态")}
toggle(row.iccid)} disabled={unavailable || busy} aria-label={row.iccid} />{row.deviceId}{row.iccid}{row.profileName || row.iccid} + {existing ? {existing.upstreamProxyId === proxy?.id ? t("已绑定此代理") : `${t("已绑定")}: ${proxyNameById.get(existing.upstreamProxyId) || existing.upstreamProxyId}`} : {("stateText" in row && row.stateText) || t("可绑定")}} +
+ {loadingProfiles ?
{t("读取 Profile 中...")}
: null} + {!loadingProfiles && rows.length === 0 ? : null} +
); diff --git a/web/src/components/proxy/UpstreamDialog.tsx b/web/src/components/proxy/UpstreamDialog.tsx index 8dceb99..d3e7dc3 100644 --- a/web/src/components/proxy/UpstreamDialog.tsx +++ b/web/src/components/proxy/UpstreamDialog.tsx @@ -108,7 +108,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o onPatch({ enabled: v })} /> diff --git a/web/src/components/proxy/UpstreamSection.tsx b/web/src/components/proxy/UpstreamSection.tsx index b890ad3..836f904 100644 --- a/web/src/components/proxy/UpstreamSection.tsx +++ b/web/src/components/proxy/UpstreamSection.tsx @@ -38,7 +38,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet {t("地址")} {t("鉴权")} {t("状态")} - {t("设备绑定")} + {t("Profile 绑定")} {t("操作")} @@ -53,12 +53,12 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
- {row.bindingCount} {t("台设备")} + {row.bindingCount} {t("个 Profile")}
- +
@@ -72,7 +72,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
{t("暂无上游代理")}
-
{t("点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。")}
+
{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。")}
) : null} {loading ?
{t("加载中...")}
: null} diff --git a/web/src/components/ui/Pagination.tsx b/web/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..83b4a25 --- /dev/null +++ b/web/src/components/ui/Pagination.tsx @@ -0,0 +1,113 @@ +import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons"; +import { cx } from "../../lib/utils"; +import { useI18n } from "../../lib/i18n"; +import { Button } from "./Button"; +import { Select } from "./Select"; + +export interface PaginationProps { + /** Current page, 1-based. */ + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + pageSizeOptions?: number[]; + className?: string; +} + +type PageItem = number | "ellipsis"; + +// Build the page-number strip: always show the first and last page, the pages +// around the current one, and collapse longer gaps into a single ellipsis +// (filling a gap of exactly one page with that page's number). +function pageWindow(current: number, pages: number): PageItem[] { + if (pages <= 7) { + return Array.from({ length: pages }, (_, index) => index + 1); + } + const left = Math.max(2, current - 1); + const right = Math.min(pages - 1, current + 1); + const kept: number[] = []; + for (let i = 1; i <= pages; i++) { + if (i === 1 || i === pages || (i >= left && i <= right)) { + kept.push(i); + } + } + const items: PageItem[] = []; + let previous = 0; + for (const page of kept) { + if (previous !== 0) { + if (page - previous === 2) items.push(previous + 1); + else if (page - previous > 2) items.push("ellipsis"); + } + items.push(page); + previous = page; + } + return items; +} + +export function Pagination({ + page, + pageSize, + total, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 20, 50], + className, +}: PaginationProps) { + const { t } = useI18n(); + const pages = Math.max(1, Math.ceil(total / pageSize)); + const current = Math.min(Math.max(1, page), pages); + const items = pageWindow(current, pages); + + if (total <= 0) return null; + + return ( +
+ {t("共 {total} 条").replace("{total}", String(total))} +
+ {onPageSizeChange ? ( +