mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 11:23:43 +08:00
feat: update schema version and refactor proxy binding logic
- Increment schema version from 11 to 12. - Modify ProxyResolver to use ICCID for device proxy binding resolution. - Update tests to reflect changes in proxy binding logic using ICCID. - Enhance DeviceBindingsDialog to manage eSIM profile bindings instead of device bindings. - Update UI components and translations to reflect the new profile binding terminology. - Implement pagination for automatic task runs in AutomaticTasksPage. - Create a new Pagination component for better navigation in lists.
This commit is contained in:
@@ -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
|
||||
|
||||
+141
-58
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-17
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 11
|
||||
const schemaVersion = 12
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -203,6 +203,7 @@ type ProxyRoute struct {
|
||||
|
||||
type ProxyRequest struct {
|
||||
DeviceID string
|
||||
ICCID string
|
||||
HomeMCC string
|
||||
HomeMNC string
|
||||
CountryCode string
|
||||
|
||||
@@ -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<ProfileProxyCandidate[]>([]);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
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<EsimOverview>(`/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<string, ProfileProxyCandidate>();
|
||||
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 (
|
||||
<Modal open={open} onClose={onClose} title={`${t("设备绑定")} — ${proxyName}`} width="max-w-2xl">
|
||||
<Modal open={open} onClose={onClose} title={`${adding ? t("添加 Profile 绑定") : t("Profile 绑定")} — ${proxyName}`} width="max-w-5xl">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
|
||||
{t("绑定后,该设备的 VoWiFi 建链和通信都会使用此 SOCKS5 代理;解绑后恢复直连。配置变更会立即尝试重连 VoWiFi。")}
|
||||
{t("VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。")}
|
||||
</div>
|
||||
{devices.length === 0 ? (
|
||||
<EmptyState title={t("暂无可绑定设备")} subtitle={t("请先在设备管理中添加设备。")}/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => {
|
||||
const binding = bindingByDevice.get(device.id);
|
||||
const boundHere = binding?.upstreamProxyId === proxy?.id;
|
||||
const boundElsewhere = !!binding && !boundHere;
|
||||
return (
|
||||
<div key={device.id} className="ui-panel-muted flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-sky-600 shadow-sm dark:bg-white/10 dark:text-sky-300">
|
||||
<DesktopRegular className="text-[18px]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-gray-900 dark:text-white">{device.name || device.id}</span>
|
||||
<span className="font-mono text-xs text-gray-400">{device.id}</span>
|
||||
{boundHere ? <Tag type="success">{t("已绑定")}</Tag> : null}
|
||||
{!device.vowifiEnabled ? <Tag type="info">{t("VoWiFi 未启用")}</Tag> : null}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-500">
|
||||
{boundHere
|
||||
? t("当前通过此代理通信")
|
||||
: boundElsewhere
|
||||
? `${t("当前绑定")}: ${proxyNameById.get(binding.upstreamProxyId) || binding.upstreamProxyId}`
|
||||
: t("当前直连")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{boundHere ? (
|
||||
<Button size="small" variant="danger" loading={busyDevice === device.id} onClick={() => onUnbind(device.id)}>
|
||||
{t("解绑")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" variant="primary" icon={<LinkRegular />} loading={busyDevice === device.id} onClick={() => onBind(device.id)}>
|
||||
{boundElsewhere ? t("切换绑定") : t("绑定设备")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs text-gray-500">{adding ? t("从设备已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 Profile")}`}</div>
|
||||
<div className="flex gap-2">
|
||||
{adding ? (
|
||||
<Button size="small" onClick={() => { setAdding(false); setSelected([]); }}>{t("返回绑定列表")}</Button>
|
||||
) : null}
|
||||
{adding ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
icon={<AddRegular />}
|
||||
loading={busy}
|
||||
disabled={selected.length === 0 || loadingProfiles}
|
||||
onClick={() => onAdd(candidates.filter((item) => selected.includes(item.iccid)))}
|
||||
>{t("添加所选")}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} loading={busy} disabled={selected.length === 0} onClick={() => onDelete(selected)}>{t("删除所选")}</Button>
|
||||
<Button size="small" variant="primary" icon={<AddRegular />} onClick={() => { setAdding(true); setSelected([]); }}>{t("添加")}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-100 dark:border-white/10">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="bg-gray-50/80 text-xs uppercase tracking-wide text-gray-500 dark:bg-white/[0.025]">
|
||||
<tr>
|
||||
<th className="w-12 px-4 py-3"><input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={selectable.length === 0 || busy} aria-label={t("全选")} /></th>
|
||||
<th className="px-4 py-3">{t("设备 ID")}</th>
|
||||
<th className="px-4 py-3">ICCID</th>
|
||||
<th className="px-4 py-3">{t("Profile 名称")}</th>
|
||||
{adding ? <th className="px-4 py-3">{t("状态")}</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{rows.map((row) => {
|
||||
const existing = bindingByICCID.get(row.iccid);
|
||||
const unavailable = adding && !!existing;
|
||||
return (
|
||||
<tr key={`${row.deviceId}:${row.iccid}`} className={unavailable ? "opacity-60" : "hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]"}>
|
||||
<td className="px-4 py-3"><input type="checkbox" checked={selected.includes(row.iccid)} onChange={() => toggle(row.iccid)} disabled={unavailable || busy} aria-label={row.iccid} /></td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{row.deviceId}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{row.iccid}</td>
|
||||
<td className="px-4 py-3 font-medium">{row.profileName || row.iccid}</td>
|
||||
{adding ? (
|
||||
<td className="px-4 py-3">
|
||||
{existing ? <Tag type={existing.upstreamProxyId === proxy?.id ? "success" : "info"}>{existing.upstreamProxyId === proxy?.id ? t("已绑定此代理") : `${t("已绑定")}: ${proxyNameById.get(existing.upstreamProxyId) || existing.upstreamProxyId}`}</Tag> : <Tag type="primary">{("stateText" in row && row.stateText) || t("可绑定")}</Tag>}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{loadingProfiles ? <div className="px-6 py-12 text-center text-sm text-gray-400">{t("读取 Profile 中...")}</div> : null}
|
||||
{!loadingProfiles && rows.length === 0 ? <EmptyState title={adding ? t("没有可显示的 eSIM Profile") : t("尚未绑定 Profile")} subtitle={adding ? t("请确认设备在线且支持 eSIM Profile 列表读取。") : t("点击添加,从设备 Profile 列表中选择。")}/>: null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -108,7 +108,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o
|
||||
</Field>
|
||||
<ToggleRow
|
||||
title={t("启用代理")}
|
||||
subtitle={t("禁用后,已绑定设备的 VoWiFi 将停止使用该线路,不会泄漏到直连")}
|
||||
subtitle={t("禁用后,已绑定 Profile 的 VoWiFi 将停止使用该线路,不会泄漏到直连")}
|
||||
checked={form.enabled}
|
||||
onChange={(v) => onPatch({ enabled: v })}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<th className="px-4 py-3">{t("地址")}</th>
|
||||
<th className="px-4 py-3">{t("鉴权")}</th>
|
||||
<th className="px-4 py-3">{t("状态")}</th>
|
||||
<th className="px-4 py-3">{t("设备绑定")}</th>
|
||||
<th className="px-4 py-3">{t("Profile 绑定")}</th>
|
||||
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -53,12 +53,12 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<td className="px-4 py-3">
|
||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
||||
<DesktopRegular className="text-[14px]" />
|
||||
<span>{row.bindingCount} {t("台设备")}</span>
|
||||
<span>{row.bindingCount} {t("个 Profile")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("设备绑定")}</Button>
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("Profile 绑定")}</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
|
||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<GlobeRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无上游代理")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
|
||||
@@ -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 (
|
||||
<div className={cx("flex flex-wrap items-center gap-x-3 gap-y-2", className)}>
|
||||
<span className="text-xs text-gray-400">{t("共 {total} 条").replace("{total}", String(total))}</span>
|
||||
<div className="flex items-center gap-1 sm:ml-auto">
|
||||
{onPageSizeChange ? (
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onChange={(value) => onPageSizeChange(Number(value))}
|
||||
options={pageSizeOptions.map((count) => ({
|
||||
value: String(count),
|
||||
label: t("{count} 条/页").replace("{count}", String(count)),
|
||||
}))}
|
||||
className="mr-1 w-24"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ChevronLeftRegular />}
|
||||
disabled={current <= 1}
|
||||
onClick={() => onPageChange(current - 1)}
|
||||
aria-label={t("上一页")}
|
||||
/>
|
||||
{items.map((item, index) =>
|
||||
item === "ellipsis" ? (
|
||||
<span key={`ellipsis-${index}`} className="px-1 text-xs text-gray-400">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={item}
|
||||
size="small"
|
||||
variant={item === current ? "primary" : "text"}
|
||||
onClick={() => onPageChange(item)}
|
||||
aria-current={item === current ? "page" : undefined}
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ChevronRightRegular />}
|
||||
disabled={current >= pages}
|
||||
onClick={() => onPageChange(current + 1)}
|
||||
aria-label={t("下一页")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export { Switch } from "./Switch";
|
||||
export { Input, Textarea } from "./Input";
|
||||
export { Select } from "./Select";
|
||||
export type { SelectOption } from "./Select";
|
||||
export { Pagination } from "./Pagination";
|
||||
export type { PaginationProps } from "./Pagination";
|
||||
export { Tabs } from "./Tabs";
|
||||
export type { TabItem } from "./Tabs";
|
||||
export { Tag } from "./Tag";
|
||||
|
||||
@@ -31,6 +31,36 @@ export const EN_DICT: Record<string, string> = {
|
||||
"插件可能已被禁用、卸载或没有注册此页面。": "The plugin may be disabled, uninstalled, or may not register this page.",
|
||||
// Device-bound VoWiFi upstream routing.
|
||||
"设备绑定": "Device Bindings",
|
||||
"Profile 绑定": "Profile Bindings",
|
||||
"添加 Profile 绑定": "Add Profile Bindings",
|
||||
"VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。":
|
||||
"VoWiFi selects its proxy by the active ICCID. An ICCID can use only one proxy, while one proxy can serve profiles across multiple devices.",
|
||||
"从设备已安装的 eSIM Profile 中选择": "Select from eSIM profiles installed on the devices",
|
||||
"个 Profile": "profiles",
|
||||
"返回绑定列表": "Back to bindings",
|
||||
"添加所选": "Add selected",
|
||||
添加: "Add",
|
||||
全选: "Select all",
|
||||
"删除所选": "Delete selected",
|
||||
"设备 ID": "Device ID",
|
||||
"Profile 名称": "Profile Name",
|
||||
"已绑定此代理": "Bound to this proxy",
|
||||
"可绑定": "Available",
|
||||
"没有可显示的 eSIM Profile": "No eSIM profiles to display",
|
||||
"尚未绑定 Profile": "No profiles bound",
|
||||
"请确认设备在线且支持 eSIM Profile 列表读取。": "Make sure the device is online and supports eSIM profile listing.",
|
||||
"点击添加,从设备 Profile 列表中选择。": "Click Add and select from the device profile list.",
|
||||
"读取 eSIM Profile 失败": "Failed to read eSIM profiles",
|
||||
"Profile 已绑定": "Profiles bound",
|
||||
"所选 ICCID 已绑定其他代理,请先删除原绑定": "A selected ICCID is bound to another proxy; delete the existing binding first",
|
||||
"所选 Profile 绑定已删除": "Selected profile bindings deleted",
|
||||
"删除绑定失败": "Failed to delete bindings",
|
||||
"绑定到该代理的 Profile 将自动解绑并恢复直连。": "Profiles bound to this proxy will be unbound and return to direct routing.",
|
||||
"管理 VoWiFi 上游代理和 eSIM Profile 绑定": "Manage VoWiFi upstream proxies and eSIM profile bindings",
|
||||
"禁用后,已绑定 Profile 的 VoWiFi 将停止使用该线路,不会泄漏到直连":
|
||||
"When disabled, bound profiles stop using this VoWiFi route and do not leak traffic to a direct connection.",
|
||||
"点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。":
|
||||
"Create a SOCKS5 upstream proxy, then bind eSIM profiles by ICCID. Unbound profiles use a direct connection.",
|
||||
"绑定后,该设备的 VoWiFi 建链和通信都会使用此 SOCKS5 代理;解绑后恢复直连。配置变更会立即尝试重连 VoWiFi。":
|
||||
"Once bound, this device uses the SOCKS5 proxy for VoWiFi setup and communications. Unbinding restores direct routing. Route changes trigger an immediate VoWiFi reconnect.",
|
||||
"暂无可绑定设备": "No devices available",
|
||||
@@ -99,6 +129,10 @@ export const EN_DICT: Record<string, string> = {
|
||||
排队中: "Queued",
|
||||
执行中: "Running",
|
||||
暂无执行记录: "No run history",
|
||||
"共 {total} 条": "{total} total",
|
||||
"{count} 条/页": "{count} / page",
|
||||
上一页: "Previous",
|
||||
下一页: "Next",
|
||||
编辑自动任务: "Edit Automatic Task",
|
||||
添加自动任务: "Add Automatic Task",
|
||||
任务名称: "Task Name",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AddRegular,
|
||||
DeleteRegular,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Pagination,
|
||||
Select,
|
||||
Switch,
|
||||
Tag,
|
||||
@@ -135,6 +136,9 @@ export default function AutomaticTasksPage() {
|
||||
const { t } = useI18n();
|
||||
const [tasks, setTasks] = useState<AutomaticTask[]>([]);
|
||||
const [runs, setRuns] = useState<AutomaticTaskRun[]>([]);
|
||||
const [runsTotal, setRunsTotal] = useState(0);
|
||||
const [runsPage, setRunsPage] = useState(1);
|
||||
const [runsPageSize, setRunsPageSize] = useState(20);
|
||||
const [devices, setDevices] = useState<DeviceListItem[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -143,16 +147,19 @@ export default function AutomaticTasksPage() {
|
||||
const [form, setForm] = useState<TaskForm>(() => emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [busy, setBusy] = useState(0);
|
||||
// Refs mirror the runs page/pageSize so the 5s poll reloads the page the user
|
||||
// is actually looking at instead of snapping back to page 1 on every tick.
|
||||
const runsPageRef = useRef(1);
|
||||
const runsPageSizeRef = useRef(20);
|
||||
|
||||
const load = useCallback(async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
try {
|
||||
const [taskData, deviceData] = await Promise.all([
|
||||
api<{ tasks?: AutomaticTask[]; runs?: AutomaticTaskRun[] }>("/automatic-tasks"),
|
||||
api<{ tasks?: AutomaticTask[] }>("/automatic-tasks"),
|
||||
api<DevicesResponse>("/devices"),
|
||||
]);
|
||||
setTasks(taskData.tasks || []);
|
||||
setRuns(taskData.runs || []);
|
||||
setDevices(deviceData.devices || []);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
@@ -161,11 +168,57 @@ export default function AutomaticTasksPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRuns = useCallback(async (page: number, pageSize: number) => {
|
||||
const request = (target: number) =>
|
||||
api<{ runs?: AutomaticTaskRun[]; total?: number }>(
|
||||
`/automatic-tasks/runs?limit=${pageSize}&offset=${(target - 1) * pageSize}`,
|
||||
);
|
||||
try {
|
||||
let data = await request(page);
|
||||
const total = data.total ?? 0;
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
// Clamp when the current page fell past the end (a larger page size, or
|
||||
// runs removed with a deleted task) instead of showing an empty slice.
|
||||
if (page > pages) {
|
||||
data = await request(pages);
|
||||
runsPageRef.current = pages;
|
||||
setRunsPage(pages);
|
||||
}
|
||||
setRuns(data.runs || []);
|
||||
setRunsTotal(total);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadRuns = useCallback(
|
||||
() => fetchRuns(runsPageRef.current, runsPageSizeRef.current),
|
||||
[fetchRuns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load(true);
|
||||
const timer = window.setInterval(() => void load(), 5000);
|
||||
void reloadRuns();
|
||||
const timer = window.setInterval(() => {
|
||||
void load();
|
||||
void reloadRuns();
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
}, [load, reloadRuns]);
|
||||
|
||||
function changeRunsPage(page: number) {
|
||||
runsPageRef.current = page;
|
||||
setRunsPage(page);
|
||||
void fetchRuns(page, runsPageSizeRef.current);
|
||||
}
|
||||
|
||||
function changeRunsPageSize(pageSize: number) {
|
||||
runsPageSizeRef.current = pageSize;
|
||||
setRunsPageSize(pageSize);
|
||||
runsPageRef.current = 1;
|
||||
setRunsPage(1);
|
||||
void fetchRuns(1, pageSize);
|
||||
}
|
||||
|
||||
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "") => {
|
||||
setProfiles([]);
|
||||
@@ -299,6 +352,7 @@ export default function AutomaticTasksPage() {
|
||||
await api(`/automatic-tasks/${task.id}/run`, { method: "POST" });
|
||||
message.success(t("任务已加入设备队列"));
|
||||
await load();
|
||||
changeRunsPage(1);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
@@ -313,6 +367,7 @@ export default function AutomaticTasksPage() {
|
||||
await api(`/automatic-tasks/${task.id}`, { method: "DELETE" });
|
||||
message.success(t("自动任务已删除"));
|
||||
await load();
|
||||
await reloadRuns();
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
@@ -393,13 +448,24 @@ export default function AutomaticTasksPage() {
|
||||
<table className="w-full min-w-[800px] text-left text-sm">
|
||||
<thead className="bg-gray-50/70 text-xs text-gray-500 dark:bg-white/[0.025]"><tr><th className="px-4 py-3">{t("任务")}</th><th className="px-4 py-3">{t("设备")}</th><th className="px-4 py-3">{t("状态")}</th><th className="px-4 py-3">{t("排队时间")}</th><th className="px-4 py-3">{t("尝试次数")}</th><th className="px-4 py-3">{t("结果")}</th></tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{runs.slice(0, 30).map((run) => (
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id}><td className="px-4 py-3 font-medium">{taskByID.get(run.taskId)?.name || `#${run.taskId}`}</td><td className="px-4 py-3">{deviceByID.get(run.deviceId)?.name || run.deviceId}</td><td className="px-4 py-3"><Tag type={run.status === "success" ? "success" : run.status === "failed" ? "danger" : run.status === "running" ? "warning" : "info"}>{({ queued: t("排队中"), running: t("执行中"), success: t("成功"), failed: t("失败") })[run.status]}</Tag></td><td className="px-4 py-3 text-xs">{formatDateTime(run.scheduledAt)}</td><td className="px-4 py-3">{run.attempts}</td><td className="px-4 py-3"><div className={run.error ? "max-w-md text-red-500" : "max-w-md text-gray-600 dark:text-gray-300"}>{run.error || run.output || "--"}</div></td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!runs.length ? <div className="p-8 text-center text-sm text-gray-400">{t("暂无执行记录")}</div> : null}
|
||||
{runsTotal > 0 ? (
|
||||
<div className="border-t border-gray-100 px-5 py-3 dark:border-white/10">
|
||||
<Pagination
|
||||
page={runsPage}
|
||||
pageSize={runsPageSize}
|
||||
total={runsTotal}
|
||||
onPageChange={changeRunsPage}
|
||||
onPageSizeChange={changeRunsPageSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
|
||||
|
||||
+30
-25
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AddRegular } from "@fluentui/react-icons";
|
||||
import { api, ApiError, apiMessage } from "../api";
|
||||
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, UpstreamProxy } from "../types";
|
||||
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
|
||||
import { usePolling } from "../lib/usePolling";
|
||||
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
|
||||
import {
|
||||
@@ -38,7 +38,7 @@ export default function ProxyPage() {
|
||||
const [upstreamProbe, setUpstreamProbe] = useState<UpstreamProbeResult | null>(null);
|
||||
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
|
||||
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
|
||||
const [busyDevice, setBusyDevice] = useState("");
|
||||
const [bindingBusy, setBindingBusy] = useState(false);
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
||||
|
||||
const proxyRows = useMemo<UpstreamRow[]>(
|
||||
@@ -55,7 +55,7 @@ export default function ProxyPage() {
|
||||
try {
|
||||
const [proxyList, bindingList, deviceList] = await Promise.all([
|
||||
api<UpstreamProxy[]>("/upstream-proxies"),
|
||||
api<DeviceProxyBinding[]>("/upstream-proxy-device-bindings"),
|
||||
api<DeviceProxyBinding[]>("/upstream-proxy-profile-bindings"),
|
||||
api<DevicesResponse>("/devices"),
|
||||
]);
|
||||
setProxies(proxyList || []);
|
||||
@@ -164,7 +164,7 @@ export default function ProxyPage() {
|
||||
<>
|
||||
{tf("确定删除上游代理“{name}”?", { name: proxy.name || proxy.id })}
|
||||
<br />
|
||||
{t("绑定到该代理的设备将自动解绑并恢复直连。")}
|
||||
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")}
|
||||
</>,
|
||||
t("确认删除"),
|
||||
{ confirmText: t("删除"), cancelText: t("取消"), type: "warning" },
|
||||
@@ -195,48 +195,53 @@ export default function ProxyPage() {
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const bindDevice = useCallback(async (deviceId: string) => {
|
||||
if (!bindingsProxy) return;
|
||||
setBusyDevice(deviceId);
|
||||
const addProfileBindings = useCallback(async (profiles: ProfileProxyCandidate[]) => {
|
||||
if (!bindingsProxy || profiles.length === 0) return;
|
||||
setBindingBusy(true);
|
||||
try {
|
||||
const result = await api<BindingMutationResult>(`/upstream-proxy-device-bindings/${encodeURIComponent(deviceId)}`, {
|
||||
method: "PUT",
|
||||
body: { upstreamProxyId: bindingsProxy.id },
|
||||
const result = await api<BindingMutationResult>("/upstream-proxy-profile-bindings", {
|
||||
method: "POST",
|
||||
body: {
|
||||
upstreamProxyId: bindingsProxy.id,
|
||||
bindings: profiles.map(({ deviceId, iccid, profileName }) => ({ deviceId, iccid, profileName })),
|
||||
},
|
||||
});
|
||||
showRouteChangeResult(result, t("设备已绑定"));
|
||||
showRouteChangeResult(result, t("Profile 已绑定"));
|
||||
await loadUpstream(false);
|
||||
} catch (error) {
|
||||
const code = error instanceof ApiError ? error.code : "";
|
||||
if (code === "device_already_bound") {
|
||||
message.error(t("该设备已绑定其他代理,请先解绑后再切换"));
|
||||
if (code === "profile_already_bound") {
|
||||
message.error(t("所选 ICCID 已绑定其他代理,请先删除原绑定"));
|
||||
} else {
|
||||
message.error(apiMessage(error) || t("绑定失败"));
|
||||
}
|
||||
} finally {
|
||||
setBusyDevice("");
|
||||
setBindingBusy(false);
|
||||
}
|
||||
}, [bindingsProxy, loadUpstream, showRouteChangeResult, t]);
|
||||
|
||||
const unbindDevice = useCallback(async (deviceId: string) => {
|
||||
setBusyDevice(deviceId);
|
||||
const deleteProfileBindings = useCallback(async (iccids: string[]) => {
|
||||
if (!bindingsProxy || iccids.length === 0) return;
|
||||
setBindingBusy(true);
|
||||
try {
|
||||
const result = await api<BindingMutationResult>(`/upstream-proxy-device-bindings/${encodeURIComponent(deviceId)}`, {
|
||||
const result = await api<BindingMutationResult>("/upstream-proxy-profile-bindings", {
|
||||
method: "DELETE",
|
||||
body: { upstreamProxyId: bindingsProxy.id, iccids },
|
||||
});
|
||||
showRouteChangeResult(result, t("设备已解绑并恢复直连"));
|
||||
showRouteChangeResult(result, t("所选 Profile 绑定已删除"));
|
||||
await loadUpstream(false);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || t("解绑失败"));
|
||||
message.error(apiMessage(error) || t("删除绑定失败"));
|
||||
} finally {
|
||||
setBusyDevice("");
|
||||
setBindingBusy(false);
|
||||
}
|
||||
}, [loadUpstream, showRouteChangeResult, t]);
|
||||
}, [bindingsProxy, loadUpstream, showRouteChangeResult, t]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title={t("代理管理")}
|
||||
subtitle={t("管理 VoWiFi 上游代理和设备绑定")}
|
||||
subtitle={t("管理 VoWiFi 上游代理和 eSIM Profile 绑定")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
|
||||
/>
|
||||
<UpstreamSection
|
||||
@@ -283,9 +288,9 @@ export default function ProxyPage() {
|
||||
proxies={proxies}
|
||||
devices={devices}
|
||||
bindings={bindings}
|
||||
busyDevice={busyDevice}
|
||||
onBind={(deviceId) => void bindDevice(deviceId)}
|
||||
onUnbind={(deviceId) => void unbindDevice(deviceId)}
|
||||
busy={bindingBusy}
|
||||
onAdd={(profiles) => void addProfileBindings(profiles)}
|
||||
onDelete={(iccids) => void deleteProfileBindings(iccids)}
|
||||
onClose={() => setBindingsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -316,11 +316,20 @@ export interface CountryRule {
|
||||
|
||||
export interface DeviceProxyBinding {
|
||||
deviceId: string;
|
||||
iccid: string;
|
||||
profileName: string;
|
||||
upstreamProxyId: string;
|
||||
reconnectRequested?: boolean;
|
||||
reconnectError?: string;
|
||||
}
|
||||
|
||||
export interface ProfileProxyCandidate {
|
||||
deviceId: string;
|
||||
iccid: string;
|
||||
profileName: string;
|
||||
stateText?: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
time: string;
|
||||
level: "debug" | "info" | "warn" | "error" | string;
|
||||
|
||||
Reference in New Issue
Block a user