From 3729ce910fb3d67482c4861001e9ff1dcb45b84c Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 1 Sep 2026 19:12:48 +0800 Subject: [PATCH] feat: support direct network exit for runtimes --- cmd/control-plane/hub.go | 113 ++++++++++++++++++-------------- cmd/control-plane/hub_test.go | 60 +++++++++++------ cmd/control-plane/phasea.go | 4 +- cmd/docker-gateway/douyin.go | 2 +- cmd/docker-gateway/main.go | 38 ++++++----- cmd/docker-gateway/main_test.go | 44 +++++++++++++ docs/deployment.md | 8 +-- internal/hub/environment.go | 13 ++-- internal/phasea/store.go | 48 +++++++------- web/src/AccountList.jsx | 6 +- web/src/AccountList.test.jsx | 5 +- web/src/BrowserList.jsx | 17 ++--- web/src/BrowserList.test.jsx | 21 ++++++ web/src/DraftDetail.jsx | 14 ++-- web/src/DraftDetail.test.jsx | 10 +++ 15 files changed, 259 insertions(+), 144 deletions(-) diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go index 8588039..622d89d 100644 --- a/cmd/control-plane/hub.go +++ b/cmd/control-plane/hub.go @@ -319,9 +319,7 @@ func environmentScheduleReadiness(environment hub.EnvironmentContext) (string, s return "blocked", "account_revoked" case environment.AccountStatus != "active": return "blocked", "account_paused" - case environment.Exit.ID == "": - return "blocked", "network_exit_missing" - case environment.Exit.HealthStatus != "healthy": + case environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy": return "blocked", "network_exit_unhealthy" case environment.RuntimeCleanupPending: return "blocked", "runtime_stop_pending" @@ -859,7 +857,7 @@ func listBrowsers(store hubStore, probe networkExitProbe, resolve func(hub.Netwo view.RuntimeInstanceID = environment.RuntimeInstanceID view.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment) view.CleanupPending = environment.RuntimeCleanupPending - view.RecoveryRequired = environment.Exit.ID == "" || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending + view.RecoveryRequired = (environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy") || environment.RuntimeCleanupPending } else if !errors.Is(contextErr, hub.ErrNotFound) { return hubError(c, contextErr) } @@ -971,9 +969,6 @@ func reconcileRuntimeSnapshot(ctx context.Context, store hubStore, probe network continue } if found && container.State == "running" { - if environment.Exit.ID == "" { - continue - } auditRecovery := !containerMatchesBinding(container, environment) || !container.ProxyReady action := actionForEnvironment("reconcile", environment) if auditRecovery { @@ -1037,9 +1032,13 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE _, err = removeGatewayRuntime(ctx, store, target, environment) return false, err } - access, _, err := verifyNetworkExit(ctx, store, probe, environment.Exit.ID) - if err != nil { - return false, discardRuntime(ctx, store, environment) + var access hub.NetworkExitAccess + var err error + if environment.Exit.ID != "" { + access, _, err = verifyNetworkExit(ctx, store, probe, environment.Exit.ID) + if err != nil { + return false, discardRuntime(ctx, store, environment) + } } target, err := store.GetGateway(ctx, environment.Gateway) if err != nil { @@ -1049,15 +1048,22 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE _, err := activateGatewayRuntime(ctx, store, target, environment, container.ID, container.NetworkID) return err == nil, err } - networkExit, err := gatewayNetworkExitFor(access, resolve) - if err != nil { - return false, discardRuntime(ctx, store, environment) + networkExit := gatewayNetworkExit{} + if environment.Exit.ID != "" { + networkExit, err = gatewayNetworkExitFor(access, resolve) + if err != nil { + return false, discardRuntime(ctx, store, environment) + } } if containerMatchesBinding(container, environment) { - status, _, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers/"+environment.Alias+"/proxy", - gatewayProxyPayload(environment, container.ID, container.NetworkID, networkExit), 30*time.Second) - if callErr == nil && status == http.StatusNoContent { + if environment.Exit.ID == "" { container.ProxyReady = true + } else { + status, _, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers/"+environment.Alias+"/proxy", + gatewayProxyPayload(environment, container.ID, container.NetworkID, networkExit), 30*time.Second) + if callErr == nil && status == http.StatusNoContent { + container.ProxyReady = true + } } if container.ProxyReady { _, err := activateGatewayRuntime(ctx, store, target, environment, container.ID, container.NetworkID) @@ -1138,13 +1144,6 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw if err := env.Fingerprint.Validate(); err != nil { return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": err.Error()}) } - exit, err := store.GetNetworkExit(c.Context(), input.NetworkExitID) - if err != nil { - return hubError(c, err) - } - if exit.HealthStatus != "healthy" { - return hubError(c, hub.ErrConflict) - } environment, created, err := store.CreateBoundEnv(c.Context(), env, input.AccountID, input.NetworkExitID) if err != nil { return hubError(c, err) @@ -1158,10 +1157,14 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action) } - access, reason, err := verifyNetworkExit(c.Context(), store, probe, input.NetworkExitID) - if err != nil { - _ = finish("failed", reason, environment) - return hubError(c, err) + var access hub.NetworkExitAccess + if input.NetworkExitID != "" { + var reason string + access, reason, err = verifyNetworkExit(c.Context(), store, probe, input.NetworkExitID) + if err != nil { + _ = finish("failed", reason, environment) + return hubError(c, err) + } } gateway, err := store.GetGateway(c.Context(), env.Gateway) if err != nil { @@ -1184,10 +1187,13 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw _ = finish("failed", "image_unavailable", environment) return hubError(c, err) } - networkExit, err := gatewayNetworkExitFor(access, resolve) - if err != nil { - _ = finish("failed", "credential_unavailable", environment) - return hubError(c, hub.ErrConflict) + networkExit := gatewayNetworkExit{} + if input.NetworkExitID != "" { + networkExit, err = gatewayNetworkExitFor(access, resolve) + if err != nil { + _ = finish("failed", "credential_unavailable", environment) + return hubError(c, hub.ErrConflict) + } } if !created { container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, env.Alias) @@ -1409,14 +1415,19 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo if !accountRunnable(environment) { return hubError(c, hub.ErrConflict) } - access, reason, err := verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID) - if err != nil { - if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil { - _ = finish("unknown", "cleanup_result_unknown", environment) - return hubError(c, cleanupErr) + var access hub.NetworkExitAccess + var err error + if environment.Exit.ID != "" { + var reason string + access, reason, err = verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID) + if err != nil { + if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil { + _ = finish("unknown", "cleanup_result_unknown", environment) + return hubError(c, cleanupErr) + } + _ = finish("failed", reason, environment) + return hubError(c, err) } - _ = finish("failed", reason, environment) - return hubError(c, err) } gateway, err := store.GetGateway(c.Context(), environment.Gateway) if err != nil { @@ -1445,14 +1456,17 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo _ = finish("failed", "image_unavailable", environment) return hubError(c, err) } - networkExit, err := gatewayNetworkExitFor(access, resolve) - if err != nil { - if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil { - _ = finish("unknown", "cleanup_result_unknown", environment) - return hubError(c, cleanupErr) + networkExit := gatewayNetworkExit{} + if environment.Exit.ID != "" { + networkExit, err = gatewayNetworkExitFor(access, resolve) + if err != nil { + if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil { + _ = finish("unknown", "cleanup_result_unknown", environment) + return hubError(c, cleanupErr) + } + _ = finish("failed", "credential_unavailable", environment) + return hubError(c, hub.ErrConflict) } - _ = finish("failed", "credential_unavailable", environment) - return hubError(c, hub.ErrConflict) } container, found, err := reconcileGatewayContainer(c.Context(), gateway, environment.Alias) if err != nil { @@ -1639,9 +1653,12 @@ func prepareRuntimeCreate(ctx context.Context, store hubStore, resolve func(hub. if err != nil { return runtimeCreateSpec{}, err } - networkExit, err := gatewayNetworkExitFor(access, resolve) - if err != nil { - return runtimeCreateSpec{}, err + networkExit := gatewayNetworkExit{} + if environment.Exit.ID != "" { + networkExit, err = gatewayNetworkExitFor(access, resolve) + if err != nil { + return runtimeCreateSpec{}, err + } } return runtimeCreateSpec{imageRef: imageRef, networkExit: networkExit}, nil } diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go index b436f95..3d01125 100644 --- a/cmd/control-plane/hub_test.go +++ b/cmd/control-plane/hub_test.go @@ -79,6 +79,7 @@ func TestResumeBlockReasonIsStable(t *testing.T) { }{ "revoked": {phasea.Account{AuthorizationStatus: "revoked"}, healthy, true, "account_revoked"}, "missing binding": {account, hub.EnvironmentContext{}, false, "binding_missing"}, + "direct exit": {account, hub.EnvironmentContext{}, true, "account_conflict"}, "unhealthy exit": {account, hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "unhealthy"}}, true, "network_exit_unhealthy"}, "cleanup pending": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeCleanupPending: true}, true, "runtime_stop_pending"}, "runtime active": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeInstanceID: "runtime-a"}, true, "runtime_active"}, @@ -375,7 +376,7 @@ func (s *memoryStore) ValidateEnvironmentRebind(_ context.Context, alias, exitID defer s.mu.Unlock() bound, ok := s.bindings[alias] if !ok { - bound = hub.EnvironmentContext{Env: s.envs[alias], AccountID: alias, BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]} + bound.BindingVersion = 1 } if bound.BindingVersion != expectedBindingVersion || bound.RuntimeCleanupPending || s.exits[exitID].HealthStatus != "healthy" { return hub.ErrConflict @@ -941,6 +942,38 @@ func TestCreateBrowserOrchestratesGateway(t *testing.T) { } } +func TestCreateBrowserSupportsDirectMachineExit(t *testing.T) { + store := newMemoryStore() + _ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true}) + gateway := &fakeGateway{token: "unit-test-gateway-token"} + server := httptest.NewServer(gateway.handler(t)) + defer server.Close() + store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} + app := fiber.New() + registerHubWithNetwork(app, store, fakeExitProbe{failure: "must_not_probe"}, func(hub.NetworkExitAccess) (string, error) { + return "", errors.New("must not resolve direct exit credentials") + }) + body := `{"alias":"direct-env","name":"直连环境","gateway":"gw-1","image_version":"148.0.7778.215",` + + `"fingerprint":{"seed":2024},"account_id":"account-a","network_exit_id":""}` + + response := do(app, http.MethodPost, "/api/browsers", body) + if response.Code != http.StatusCreated { + t.Fatalf("expected direct create 201, got %d: %s", response.Code, response.Body.String()) + } + bound := store.bindings["direct-env"] + if bound.Exit.ID != "" || bound.RuntimeID == "" { + t.Fatalf("direct binding was not activated: %#v", bound) + } + requests := gateway.recorded() + if len(requests) != 1 || requests[0].body["network_exit_id"] != "" { + t.Fatalf("direct create sent an unexpected gateway payload: %#v", requests) + } + exit, ok := requests[0].body["network_exit"].(map[string]any) + if !ok || exit["protocol"] != "" || exit["host"] != "" || exit["port"] != float64(0) { + t.Fatalf("direct create must not send proxy settings: %#v", requests[0].body) + } +} + func TestExitFailuresStopCreateBeforeGateway(t *testing.T) { for _, test := range []struct { name string @@ -4275,41 +4308,28 @@ func TestDisableExitPropagatesUnknownGatewayReadAndPreservesLease(t *testing.T) } } -func TestLegacyNullBindingIsListableAndExplicitlyRecoverable(t *testing.T) { +func TestDirectBindingIsListable(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{ Seed: 1, ProxyServer: "http://legacy:secret@proxy.example:8080", DisableNonProxiedUDP: true, }} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - RuntimeInstanceID: "legacy-runtime", RuntimeID: "legacy-container", + RuntimeInstanceID: "direct-runtime", RuntimeID: "direct-container", RuntimeNetworkID: "direct-network", } _ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ - ID: "legacy-container", Alias: "account-a", State: "running", BindingVersion: 1, + ID: "direct-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkID: "direct-network", ProxyReady: true, }}} app := newTestApp(t, store, gateway) response := do(app, http.MethodGet, "/api/browsers", "") if response.Code != http.StatusOK { - t.Fatalf("legacy NULL binding broke browser listing: %d: %s", response.Code, response.Body.String()) + t.Fatalf("direct binding broke browser listing: %d: %s", response.Code, response.Body.String()) } var views []envView - if err := json.Unmarshal(response.Body.Bytes(), &views); err != nil || len(views) != 1 || !views[0].RecoveryRequired || views[0].NetworkExitID != "" { - t.Fatalf("legacy recovery state was not visible: %#v err=%v", views, err) - } - - response = do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-1"}`) - if response.Code != http.StatusOK { - t.Fatalf("legacy explicit recovery failed: %d: %s", response.Code, response.Body.String()) - } - requests := gateway.recorded() - if len(requests) != 5 || requests[3].method != http.MethodDelete || requests[4].method != http.MethodPost { - t.Fatalf("legacy recovery must inspect, remove, then recreate: %#v", requests) - } - encoded, _ := json.Marshal(requests[4].body) - if strings.Contains(string(encoded), "legacy") || strings.Contains(string(encoded), "secret") { - t.Fatalf("legacy Config.Cmd credentials reached the recovered runtime: %s", encoded) + if err := json.Unmarshal(response.Body.Bytes(), &views); err != nil || len(views) != 1 || views[0].RecoveryRequired || views[0].NetworkExitID != "" || views[0].ScheduleStatus != "ready" { + t.Fatalf("direct runtime state was not visible: %#v err=%v", views, err) } } diff --git a/cmd/control-plane/phasea.go b/cmd/control-plane/phasea.go index 49593dd..a94b279 100644 --- a/cmd/control-plane/phasea.go +++ b/cmd/control-plane/phasea.go @@ -334,9 +334,7 @@ func resumeBlockReason(account phasea.Account, environment hub.EnvironmentContex return "account_revoked" case !bindingFound: return "binding_missing" - case environment.Exit.ID == "": - return "network_exit_missing" - case environment.Exit.HealthStatus != "healthy": + case environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy": return "network_exit_unhealthy" case environment.RuntimeCleanupPending: return "runtime_stop_pending" diff --git a/cmd/docker-gateway/douyin.go b/cmd/docker-gateway/douyin.go index 6124421..a5b70ad 100644 --- a/cmd/docker-gateway/douyin.go +++ b/cmd/docker-gateway/douyin.go @@ -124,7 +124,7 @@ func decodeRestrictedBrowserRequest(body []byte, target any) error { func validDouyinGeneration(input douyinGenerationRequest) bool { return input.BindingVersion > 0 && exitIDPattern.MatchString(input.RuntimeID) && - exitIDPattern.MatchString(input.NetworkID) && exitIDPattern.MatchString(input.NetworkExitID) + exitIDPattern.MatchString(input.NetworkID) && (input.NetworkExitID == "" || exitIDPattern.MatchString(input.NetworkExitID)) } func (api gateway) requireDouyinGeneration(alias string, input douyinGenerationRequest) error { diff --git a/cmd/docker-gateway/main.go b/cmd/docker-gateway/main.go index a5f0177..9ea8684 100644 --- a/cmd/docker-gateway/main.go +++ b/cmd/docker-gateway/main.go @@ -353,6 +353,7 @@ func (api gateway) list(c fiber.Ctx) error { } bindingVersion, _ := strconv.ParseInt(container.Labels[bindingVersionLabel], 10, 64) proxyPort, _ := strconv.Atoi(container.Labels[proxyPortLabel]) + direct := container.Labels[networkExitLabel] == "" browsers = append(browsers, browser{ ID: container.ID, Alias: alias, @@ -363,7 +364,7 @@ func (api gateway) list(c fiber.Ctx) error { BindingVersion: bindingVersion, NetworkExitID: container.Labels[networkExitLabel], NetworkID: container.Labels[networkIDLabel], - ProxyReady: api.proxies.ready(alias, proxyPort, container.ID, container.Labels[networkIDLabel]), + ProxyReady: direct || api.proxies.ready(alias, proxyPort, container.ID, container.Labels[networkIDLabel]), }) } return writeJSON(c, http.StatusOK, browsers) @@ -397,6 +398,7 @@ func (api gateway) create(c fiber.Ctx) error { } else if !errors.Is(err, os.ErrNotExist) { return writeError(c, statusFor(err), err) } + direct := input.NetworkExitID == "" network, proxyServer, undoProxy := "none", "", func() {} var networkGeneration tenantNetworkGeneration keepNetwork := input.Stopped @@ -425,9 +427,11 @@ func (api gateway) create(c fiber.Ctx) error { return writeNetworkError(c, http.StatusBadGateway, errors.New("configure isolated browser network"), networkGeneration.ID) } network = networkGeneration.ID - proxyServer, undoProxy, err = api.proxies.configure(input.Alias, input.BindingVersion, bindHost, 0, input.NetworkExit, networkGeneration.ID) - if err != nil { - return writeNetworkError(c, statusFor(err), errors.Join(errors.New("configure in-memory proxy"), err), networkGeneration.ID) + if !direct { + proxyServer, undoProxy, err = api.proxies.configure(input.Alias, input.BindingVersion, bindHost, 0, input.NetworkExit, networkGeneration.ID) + if err != nil { + return writeNetworkError(c, statusFor(err), errors.Join(errors.New("configure in-memory proxy"), err), networkGeneration.ID) + } } } keepProxy := false @@ -439,7 +443,7 @@ func (api gateway) create(c fiber.Ctx) error { pidsLimit := int64(512) cmd := append([]string{}, input.Cmd...) - if !input.Stopped { + if !input.Stopped && !direct { cmd = append(cmd[:len(cmd)-1], "--proxy-server="+proxyServer, "--disable-non-proxied-udp", cmd[len(cmd)-1]) } payload := map[string]any{ @@ -508,14 +512,16 @@ func (api gateway) create(c fiber.Ctx) error { } } if !input.Stopped { - if !api.proxies.bind(input.Alias, input.BindingVersion, proxyServer, created.ID, networkGeneration.ID) { - cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent) - if cleanupErr != nil { - return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("proxy generation changed and container cleanup failed: %w", cleanupErr), networkGeneration.ID) + if !direct { + if !api.proxies.bind(input.Alias, input.BindingVersion, proxyServer, created.ID, networkGeneration.ID) { + cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent) + if cleanupErr != nil { + return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("proxy generation changed and container cleanup failed: %w", cleanupErr), networkGeneration.ID) + } + return writeNetworkError(c, http.StatusConflict, errGenerationConflict, networkGeneration.ID) } - return writeNetworkError(c, http.StatusConflict, errGenerationConflict, networkGeneration.ID) + undoProxy = func() { api.proxies.remove(input.Alias, input.BindingVersion, created.ID) } } - undoProxy = func() { api.proxies.remove(input.Alias, input.BindingVersion, created.ID) } if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil { cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent) if cleanupErr != nil { @@ -524,7 +530,7 @@ func (api gateway) create(c fiber.Ctx) error { return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err), networkGeneration.ID) } } - keepProxy, keepNetwork = !input.Stopped, true + keepProxy, keepNetwork = !input.Stopped && !direct, true return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "alias": input.Alias, "network_id": networkGeneration.ID}) } @@ -541,8 +547,10 @@ func validateCreate(input createRequest) error { if !volumePattern.MatchString(input.Volume) { return errors.New("volume must be a valid volume name") } - if input.BindingVersion < 1 || (!input.Stopped && !exitIDPattern.MatchString(input.NetworkExitID)) || - (input.Stopped && (input.NetworkExitID != "" || input.NetworkExit != (gatewayProxyExit{}))) { + direct := input.NetworkExitID == "" && input.NetworkExit == (gatewayProxyExit{}) + if input.BindingVersion < 1 || (input.Stopped && !direct) || + (!input.Stopped && !direct && !exitIDPattern.MatchString(input.NetworkExitID)) || + (input.NetworkExitID == "") != (input.NetworkExit == (gatewayProxyExit{})) { return errors.New("binding_version and network_exit_id must identify the current binding") } if len(input.Cmd) == 0 || len(input.Cmd) > 64 || input.Cmd[len(input.Cmd)-1] != "about:blank" { @@ -561,7 +569,7 @@ func validateCreate(input createRequest) error { if total > 4096 { return errors.New("cmd arguments exceed 4096 characters") } - if input.Stopped { + if input.Stopped || direct { return nil } proxy := input.NetworkExit diff --git a/cmd/docker-gateway/main_test.go b/cmd/docker-gateway/main_test.go index 5a137b4..240ef49 100644 --- a/cmd/docker-gateway/main_test.go +++ b/cmd/docker-gateway/main_test.go @@ -572,6 +572,50 @@ func TestGatewayCreatesConstrainedBrowserWithPlatformSpec(t *testing.T) { } } +func TestGatewayCreatesDirectBrowserWithoutProxyArguments(t *testing.T) { + var created map[string]any + docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): + _, _ = response.Write([]byte(`{}`)) + case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix): + response.WriteHeader(http.StatusNotFound) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): + if err := json.NewDecoder(request.Body).Decode(&created); err != nil { + t.Fatal(err) + } + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(`{"Id":"container-id"}`)) + case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"): + response.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) + } + }) + defer server.Close() + + handler := newGateway(docker, "creatorhub_browser", testToken) + body := `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` + + `"cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` + + `"binding_version":1,"network_exit_id":"","network_exit":{}}` + response := httptest.NewRecorder() + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body))) + + if response.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String()) + } + cmd := created["Cmd"].([]any) + encoded, _ := json.Marshal(cmd) + if len(cmd) != 2 || strings.Contains(string(encoded), "proxy") { + t.Fatalf("direct runtime received proxy arguments: %#v", cmd) + } + host := created["HostConfig"].(map[string]any) + labels := created["Labels"].(map[string]any) + if host["NetworkMode"] != "network-account-a" || labels[networkExitLabel] != "" || labels[proxyPortLabel] != "0" { + t.Fatalf("direct runtime metadata is invalid: host=%#v labels=%#v", host, labels) + } +} + func TestGatewayCreateUsesCapturedNetworkIDAcrossNameReplacement(t *testing.T) { networkID := "" members := map[string]string{} diff --git a/docs/deployment.md b/docs/deployment.md index 17380ce..ac5f7bf 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -94,11 +94,11 @@ docker compose logs --tail=200 creator-hub docker-gateway postgres 页面路径如下: -1. 登录后在「网关管理」注册 http://docker-gateway:8081,令牌必须等于 GATEWAY_TOKEN。 +1. 登录后在「网关管理」注册 GATEWAY_TOKEN。 2. 在「镜像版本」添加并启用一个可拉取的 fingerprint-chromium 镜像。 -3. 在「网络出口」创建出口并点击「检测」,健康状态必须为「健康」;这里只填写凭据引用 ID,不填写密码、Cookie 或 token。 +3. 可选:在「网络出口」创建出口并点击「检测」,健康状态必须为「健康」;这里只填写凭据引用 ID,不填写密码、Cookie 或 token。留空则使用网关所在机器的网络出口直连。 4. 在「社媒账号」创建平台为 mock 的账号;创建后默认暂停。 -5. 在「运行环境」选择该账号、健康出口和镜像,使用正整数 Fingerprint Seed 创建环境;随后在账号详情点击「恢复账号」,再在「运行环境」点击「启动」。 +5. 在「运行环境」选择该账号、镜像和可选的健康出口,使用正整数 Fingerprint Seed 创建环境;随后在账号详情点击「恢复账号」,再在「运行环境」点击「启动」。 6. 在账号详情创建文本草稿,点击「核对草稿」,勾选“我已核对当前账号、草稿内容、运行环境和固定出口”,依次执行「确认当前快照」→「保存确认」→「加入队列」。 7. Mock 执行器没有独立页面,使用下面的 POST /api/phase-a/mock/execute,再到「任务中心」和「审计」核对结果。 @@ -231,7 +231,7 @@ docker compose stop | 手工验证脚本在 `:?` 处退出 | CREATORHUB_PORT、GATEWAY_TOKEN、CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否都已 export | 在启动 Compose 的同一个 shell 中 export 完整变量集;不要只依赖 `.env` 或 Compose 默认值 | | creator-hub 未启动 | docker compose ps、docker compose logs --tail=200 postgres docker-gateway creator-hub | 先确认 PostgreSQL 与网关 health 为 healthy;网关需能访问 /var/run/docker.sock,DOCKER_GID 使用 stat -c '%g' /var/run/docker.sock 的实际值 | | API 返回 401 | curl 是否带 --user CONTROL_PLANE_USERNAME:CONTROL_PLANE_PASSWORD | /healthz 不需要认证,其余 /api/* 需要控制面 Basic Auth | -| /api/browsers 返回 503 或网关不可用 | 网关注册的 Endpoint、令牌与 Compose 的 GATEWAY_TOKEN | Endpoint 在 Compose 网络内应为 http://docker-gateway:8081;重新注册时令牌必须完全一致 | +| /api/browsers 返回 503 或网关不可用 | 网关注册的 Endpoint、令牌与 Compose 的 GATEWAY_TOKEN | Endpoint 在 Compose 网络内应为 | | 出口一直是 unchecked/unhealthy | 出口协议、主机、端口;控制面容器到代理的连通性;last_check_reason | 先用无认证代理完成最小验证;有认证时只提供已配置的凭据引用,不把认证值放到请求、日志或文档 | | 创建环境时报 image_unavailable 或拉取超时 | image_ref 格式、镜像架构、Docker daemon 的 registry 登录和网络 | 版本表中的镜像必须可被 Docker daemon 拉取;缺失镜像会由网关按引用拉取,最长约 10 分钟 | | 恢复/入队返回 503 | readiness、GET /api/browsers/、GET /api/network-exits/ | binding_missing、network_exit_unhealthy、runtime_missing 表示固定资源未就绪;先修复出口并启动原环境,不要换出口重试 | diff --git a/internal/hub/environment.go b/internal/hub/environment.go index 0be2e13..aca4b7c 100644 --- a/internal/hub/environment.go +++ b/internal/hub/environment.go @@ -355,7 +355,7 @@ func invalidateAccountsForExit(ctx context.Context, tx *sql.Tx, exitID string) ( func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID string) (EnvironmentContext, bool, error) { env.Alias, env.Name = strings.TrimSpace(env.Alias), strings.TrimSpace(env.Name) if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) || !aliasPattern.MatchString(accountID) || - !exitIDPattern.MatchString(exitID) || !gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) || + (exitID != "" && !exitIDPattern.MatchString(exitID)) || !gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) || env.Fingerprint.ProxyServer != "" { return EnvironmentContext{}, false, ErrInvalid } @@ -391,15 +391,16 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s if err := tx.QueryRowContext(ctx, ` INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) SELECT $1, $2, $3, image.version, $5 - FROM browser_image image, social_account account, network_exit network + FROM browser_image image, social_account account WHERE image.version = $4 AND image.enabled AND account.id = $6 AND account.status = 'paused' - AND account.authorization_status = 'authorized' AND network.id = $7 AND network.health_status = 'healthy' + AND account.authorization_status = 'authorized' + AND ($7 = '' OR EXISTS (SELECT 1 FROM network_exit WHERE id = $7 AND health_status = 'healthy')) RETURNING alias`, env.Alias, env.Name, env.Gateway, env.ImageVersion, encoded, accountID, exitID).Scan(&created); err != nil { return EnvironmentContext{}, false, rowError(err) } if _, err := tx.ExecContext(ctx, ` INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id) - VALUES ($1, $1, $2, $3)`, accountID, env.Alias, exitID); err != nil { + VALUES ($1, $1, $2, NULLIF($3, ''))`, accountID, env.Alias, exitID); err != nil { return EnvironmentContext{}, false, publicDatabaseError(err) } if _, err := tx.ExecContext(ctx, `UPDATE social_account SET version = version + 1, updated_at = now() WHERE id = $1`, accountID); err != nil { @@ -614,8 +615,8 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi if len(networkIDs) == 1 { networkID = networkIDs[0] } - if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || bindingVersion < 1 || !exitIDPattern.MatchString(exitID) || - !exitIDPattern.MatchString(networkID) || len(networkIDs) != 1 { + if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || bindingVersion < 1 || + (exitID != "" && !exitIDPattern.MatchString(exitID)) || !exitIDPattern.MatchString(networkID) || len(networkIDs) != 1 { return EnvironmentContext{}, ErrInvalid } tx, err := s.db.BeginTx(ctx, nil) diff --git a/internal/phasea/store.go b/internal/phasea/store.go index c1a5d53..32a1f46 100644 --- a/internal/phasea/store.go +++ b/internal/phasea/store.go @@ -858,15 +858,14 @@ func (s *Store) EnqueueConfirmation(ctx context.Context, confirmationID string) } return Task{}, false, publicDatabaseError(err) } - if !networkExitID.Valid { - return Task{}, false, &ReadinessError{Reason: "network_exit_missing", Unavailable: true} - } - var exitStatus string - if err := tx.QueryRowContext(ctx, `SELECT health_status FROM network_exit WHERE id = $1 FOR SHARE`, networkExitID.String).Scan(&exitStatus); err != nil { - return Task{}, false, publicDatabaseError(err) - } - if exitStatus != "healthy" { - return Task{}, false, &ReadinessError{Reason: "network_exit_unhealthy", Unavailable: true} + if networkExitID.Valid { + var exitStatus string + if err := tx.QueryRowContext(ctx, `SELECT health_status FROM network_exit WHERE id = $1 FOR SHARE`, networkExitID.String).Scan(&exitStatus); err != nil { + return Task{}, false, publicDatabaseError(err) + } + if exitStatus != "healthy" { + return Task{}, false, &ReadinessError{Reason: "network_exit_unhealthy", Unavailable: true} + } } if cleanupPending { return Task{}, false, &ReadinessError{Reason: "runtime_stop_pending", Unavailable: true} @@ -1035,8 +1034,8 @@ func (s *Store) ResumeAccount(ctx context.Context, accountID string) error { if err := tx.QueryRowContext(ctx, ` SELECT EXISTS ( SELECT 1 FROM environment_binding binding - JOIN network_exit network ON network.id = binding.network_exit_id - WHERE binding.account_id = $1 AND network.health_status = 'healthy' + LEFT JOIN network_exit network ON network.id = binding.network_exit_id + WHERE binding.account_id = $1 AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy') AND NOT binding.runtime_cleanup_pending AND NOT EXISTS ( SELECT 1 FROM runtime_instance runtime @@ -1184,7 +1183,7 @@ func (s *Store) ResumeTask(ctx context.Context, taskID string) error { JOIN confirmation confirmation ON confirmation.id = task.confirmation_id JOIN environment_binding binding ON binding.account_id = task.account_id JOIN browser_env environment ON environment.alias = binding.browser_env_alias - JOIN network_exit network ON network.id = binding.network_exit_id + LEFT JOIN network_exit network ON network.id = binding.network_exit_id JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL WHERE task.id = $1 AND task.state IN ('policy_hold', 'needs_confirmation') AND task.verification_result = 'not_executed' @@ -1194,9 +1193,9 @@ func (s *Store) ResumeTask(ctx context.Context, taskID string) error { AND confirmation.account_id = task.account_id AND confirmation.account_version = task.account_version AND confirmation.draft_id = task.draft_id AND confirmation.draft_version = task.draft_version AND confirmation.version = task.confirmation_version - AND network.health_status = 'healthy' AND NOT binding.runtime_cleanup_pending + AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy') AND NOT binding.runtime_cleanup_pending AND runtime.binding_version = binding.version AND runtime.lease_until > now() - FOR UPDATE OF task, account, draft, confirmation, binding, environment, network, runtime`, taskID). + FOR UPDATE OF task, account, draft, confirmation, binding, environment, runtime`, taskID). Scan(&accountID, &confirmationID, &confirmationVersion) if errors.Is(err, sql.ErrNoRows) { task, taskErr := scanTask(tx.QueryRowContext(ctx, ` @@ -1255,8 +1254,8 @@ func taskReadinessReason(ctx context.Context, queryer rowQuerier, taskID string) OR confirmation.version <> task.confirmation_version THEN 'confirmation_version_changed' WHEN binding.id IS NULL THEN 'binding_missing' WHEN environment.alias IS NULL THEN 'environment_missing' - WHEN network.id IS NULL THEN 'exit_missing' - WHEN network.health_status <> 'healthy' THEN 'exit_unhealthy' + WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing' + WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy' WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending' WHEN runtime.id IS NULL THEN 'runtime_missing' WHEN runtime.binding_version IS DISTINCT FROM binding.version THEN 'binding_version_changed' @@ -1382,7 +1381,7 @@ func (s *Store) claim(ctx context.Context, workerID string) (Execution, error) { JOIN confirmation c ON c.id = t.confirmation_id JOIN environment_binding binding ON binding.account_id = a.id JOIN browser_env environment ON environment.alias = binding.browser_env_alias - JOIN network_exit network ON network.id = binding.network_exit_id + LEFT JOIN network_exit network ON network.id = binding.network_exit_id JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL AND runtime.lease_until > now() AND runtime.binding_version = binding.version WHERE t.state = 'queued' AND a.status = 'active' AND a.authorization_status = 'authorized' @@ -1391,9 +1390,9 @@ func (s *Store) claim(ctx context.Context, workerID string) (Execution, error) { AND c.account_id = t.account_id AND c.account_version = t.account_version AND c.draft_id = t.draft_id AND c.draft_version = t.draft_version AND c.version = t.confirmation_version - AND network.health_status = 'healthy' AND NOT binding.runtime_cleanup_pending + AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy') AND NOT binding.runtime_cleanup_pending ORDER BY t.created_at, t.id - FOR UPDATE OF t, a, binding, network, runtime SKIP LOCKED LIMIT 1 + FOR UPDATE OF t, a, binding, runtime SKIP LOCKED LIMIT 1 ) UPDATE operation_task t SET state = 'executing', hold_reason = NULL, verification_result = NULL, verified_at = NULL, verified_by = NULL, @@ -1448,8 +1447,8 @@ func (s *Store) complete(ctx context.Context, workerID string, execution Executi OR confirmation.draft_version <> task.draft_version OR confirmation.version <> task.confirmation_version THEN 'confirmation_version_changed' WHEN binding.id IS NULL THEN 'binding_missing' WHEN environment.alias IS NULL THEN 'environment_missing' - WHEN network.id IS NULL THEN 'exit_missing' - WHEN network.health_status <> 'healthy' THEN 'exit_unhealthy' + WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing' + WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy' WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending' WHEN runtime.id IS NULL OR runtime.lease_until <= now() THEN 'runtime_missing' WHEN claim.binding_version IS DISTINCT FROM binding.version @@ -1600,8 +1599,8 @@ func quarantineInvalid(ctx context.Context, tx *sql.Tx) ([]taskstate.Transition, OR confirmation.version <> t.confirmation_version THEN 'confirmation_version_changed' WHEN binding.id IS NULL THEN 'binding_missing' WHEN environment.alias IS NULL THEN 'environment_missing' - WHEN network.id IS NULL THEN 'exit_missing' - WHEN network.health_status <> 'healthy' THEN 'exit_unhealthy' + WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing' + WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy' WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending' WHEN runtime.id IS NULL THEN 'runtime_missing' WHEN runtime.binding_version IS DISTINCT FROM binding.version THEN 'binding_version_changed' @@ -1622,7 +1621,8 @@ func quarantineInvalid(ctx context.Context, tx *sql.Tx) ([]taskstate.Transition, OR confirmation.id IS NULL OR confirmation.account_id <> t.account_id OR confirmation.account_version <> t.account_version OR confirmation.draft_id <> t.draft_id OR confirmation.draft_version <> t.draft_version OR confirmation.version <> t.confirmation_version - OR binding.id IS NULL OR environment.alias IS NULL OR network.id IS NULL OR network.health_status <> 'healthy' + OR binding.id IS NULL OR environment.alias IS NULL + OR (binding.network_exit_id IS NOT NULL AND (network.id IS NULL OR network.health_status <> 'healthy')) OR binding.runtime_cleanup_pending OR runtime.id IS NULL OR runtime.binding_version IS DISTINCT FROM binding.version OR runtime.lease_until <= now() ) diff --git a/web/src/AccountList.jsx b/web/src/AccountList.jsx index d3bc0ad..40d04f7 100644 --- a/web/src/AccountList.jsx +++ b/web/src/AccountList.jsx @@ -63,8 +63,8 @@ export function accountReadiness(account, binding, bindingError = false) { if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false } const resourceReason = binding.cleanup_pending ? 'runtime_stop_pending' - : binding.network_exit_health !== 'healthy' - ? (binding.network_exit_id ? 'network_exit_unhealthy' : 'network_exit_missing') + : binding.network_exit_id && binding.network_exit_health !== 'healthy' + ? 'network_exit_unhealthy' : '' if (resourceReason) return { label: reasonLabels[resourceReason], reason: resourceReason, canResume: false, ready: false } if (account.runtime_status === 'paused') { @@ -234,7 +234,7 @@ export function AccountDetail() { } sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。 账号状态授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider} - 固定资源{browsersError ? 运行环境、固定出口与 readiness 状态未知;重试成功后再执行依赖资源状态的操作。 : binding ? <>运行环境:{binding.name}({binding.alias})固定出口:{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}绑定版本:{binding.binding_version}不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'} : <>尚未绑定运行环境与固定出口,因此不能恢复或排队。} + 固定资源{browsersError ? 运行环境与网络出口状态未知;重试成功后再执行依赖资源状态的操作。 : binding ? <>运行环境:{binding.name}({binding.alias})网络出口:{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}绑定版本:{binding.binding_version}不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'} : <>尚未绑定运行环境,因此不能恢复或排队。} 文本草稿 diff --git a/web/src/AccountList.test.jsx b/web/src/AccountList.test.jsx index 1606ea5..eb39c91 100644 --- a/web/src/AccountList.test.jsx +++ b/web/src/AccountList.test.jsx @@ -68,6 +68,7 @@ describe('AccountList', () => { it('distinguishes resumable bindings from missing resources', () => { expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true }) + expect(accountReadiness(account, { ...binding, network_exit_id: '', network_exit_health: 'unchecked' })).toMatchObject({ label: '资源就绪,可恢复', canResume: true }) expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false }) expect(accountReadiness(account, undefined, true)).toMatchObject({ label: '环境状态未知', canResume: false }) }) @@ -96,8 +97,8 @@ describe('AccountDetail', () => { render(} />) expect(await screen.findByText(/环境不可用(502)/)).toBeTruthy() - expect(screen.getByText(/运行环境、固定出口与 readiness 状态未知/)).toBeTruthy() - expect(screen.queryByText(/尚未绑定运行环境与固定出口/)).toBeNull() + expect(screen.getByText(/运行环境与网络出口状态未知/)).toBeTruthy() + expect(screen.queryByText(/尚未绑定运行环境/)).toBeNull() expect(screen.getByRole('button', { name: '恢复账号' }).disabled).toBe(true) expect(screen.getByRole('button', { name: '暂停账号' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: '重试环境状态' })) diff --git a/web/src/BrowserList.jsx b/web/src/BrowserList.jsx index c2ebf4e..c8b23b2 100644 --- a/web/src/BrowserList.jsx +++ b/web/src/BrowserList.jsx @@ -13,7 +13,6 @@ import { DialogTitle, FormControlLabel, IconButton, - InputAdornment, MenuItem, Paper, Stack, @@ -136,7 +135,6 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy } const availableAccounts = accounts.filter(account => account.authorization_status === 'authorized' && account.runtime_status === 'paused') const healthyExits = networkExits.filter(exit => exit.health_status === 'healthy') const defaultAccountID = availableAccounts[0]?.id ?? '' - const defaultExitID = healthyExits[0]?.id ?? '' const update = (key, value) => setForm(current => ({ ...current, [key]: value })) const toggleSpoofing = option => setForm(current => ({ ...current, @@ -154,13 +152,9 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy } useEffect(() => { if (form.account_id === '' && defaultAccountID !== '') update('account_id', defaultAccountID) }, [defaultAccountID, form.account_id]) - useEffect(() => { - if (form.network_exit_id === '' && defaultExitID !== '') update('network_exit_id', defaultExitID) - }, [defaultExitID, form.network_exit_id]) - const seedNumber = Number(form.seed) const valid = form.name.trim() !== '' && aliasPattern.test(form.alias) && form.gateway !== '' && - form.image_version !== '' && form.account_id !== '' && form.network_exit_id !== '' && + form.image_version !== '' && form.account_id !== '' && Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647 const submit = async event => { @@ -217,8 +211,9 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy } - {label('env-exit', '网络出口', true)} - update('network_exit_id', event.target.value)} disabled={healthyExits.length === 0} helperText={healthyExits.length === 0 ? '请先检查并启用健康出口' : ' '}> + {label('env-exit', '网络出口')} + update('network_exit_id', event.target.value)} helperText="留空时使用当前机器网络出口直连"> + 当前机器直连 {healthyExits.map(exit => {exit.id} · {exit.protocol}://{exit.host}:{exit.port})} @@ -300,7 +295,7 @@ function RuntimeTable({ runtimes, busy, onAction }) { {runtime.name}{runtime.alias} · seed {runtime.fingerprint?.seed} - {runtime.account_id || '未绑定账号'}{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version} + {runtime.account_id || '未绑定账号'}{runtime.network_exit_id || '当前机器直连'} · {runtime.image_version} {runtime.endpoint ? : } @@ -312,7 +307,7 @@ function RuntimeTable({ runtimes, busy, onAction }) { } function RuntimeCards({ runtimes, busy, onAction }) { - return {runtimes.map(runtime => {runtime.name}{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}账号:{runtime.account_id || '未绑定'} · 固定出口:{runtime.network_exit_id || '未绑定'}{runtime.endpoint ? : null})} + return {runtimes.map(runtime => {runtime.name}{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}账号:{runtime.account_id || '未绑定'} · 网络出口:{runtime.network_exit_id || '当前机器直连'}{runtime.endpoint ? : null})} } export function BrowserList() { diff --git a/web/src/BrowserList.test.jsx b/web/src/BrowserList.test.jsx index 443a8f3..b223a0b 100644 --- a/web/src/BrowserList.test.jsx +++ b/web/src/BrowserList.test.jsx @@ -126,6 +126,8 @@ describe('BrowserList', () => { fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '店铺三号' } }) fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'shop-03' } }) + fireEvent.mouseDown(screen.getByRole('combobox', { name: '网络出口' })) + fireEvent.click(await screen.findByRole('option', { name: /exit-1/ })) fireEvent.click(screen.getByRole('button', { name: '创建环境' })) await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: { @@ -141,6 +143,25 @@ describe('BrowserList', () => { expect(screen.queryByLabelText('禁用非代理 UDP')).toBeNull() }) + it('creates an env with the current machine exit when network exit is empty', async () => { + const dataProvider = provider({ getList: vi.fn(resource => { + if (resource === 'network-exits') return Promise.resolve({ data: [], total: 0 }) + return provider().getList(resource) + }) }) + render() + await screen.findAllByText('店铺一号') + + fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '直连环境' } }) + fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'direct-env' } }) + fireEvent.click(screen.getByRole('button', { name: '创建环境' })) + + await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: expect.objectContaining({ + alias: 'direct-env', + network_exit_id: '', + }) })) + expect(screen.getByText('留空时使用当前机器网络出口直连')).toBeTruthy() + }) + it('keeps binding input after a 409 conflict', async () => { const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409)) }) render() diff --git a/web/src/DraftDetail.jsx b/web/src/DraftDetail.jsx index aa2afb5..ac57c72 100644 --- a/web/src/DraftDetail.jsx +++ b/web/src/DraftDetail.jsx @@ -66,7 +66,7 @@ export function DraftDetail() { confirmation.account_version === draft.account.version && confirmation.draft_version === draft.version) const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === draft.account_id) const readiness = accountReadiness(draft.account, binding, Boolean(browsersError)) - const canConfirm = snapshotCurrent && Boolean(binding?.network_exit_id) && !browsersError + const canConfirm = snapshotCurrent && Boolean(binding) && !browsersError const canEnqueue = Boolean(currentConfirmation) && snapshotCurrent && readiness.ready function openDialog() { @@ -114,8 +114,8 @@ export function DraftDetail() { {message ? {message.text} : null} - {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>环境不可用:当前运行环境与固定出口状态未知,不能确认或排队。 : null} - {!snapshotCurrent ? 打开最新版本} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本,请刷新到版本 {latestVersion} 后重新核对。 : null} + {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>环境不可用:当前运行环境与网络出口状态未知,不能确认或排队。 : null} + {snapshotCurrent ? null : 打开最新版本} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本,请刷新到版本 {latestVersion} 后重新核对。} @@ -129,7 +129,7 @@ export function DraftDetail() { 账号版本:{draft.account.version} 草稿版本:{draft.version} 运行环境:{browsersError ? '状态未知' : (binding?.name || '未绑定')} - 固定出口:{browsersError ? '状态未知' : (binding?.network_exit_id || '未绑定')} + 网络出口:{browsersError ? '状态未知' : (binding?.network_exit_id || '当前机器直连')} {readiness.ready ? : }{readiness.label} @@ -144,12 +144,12 @@ export function DraftDetail() { 显式核对与入队 - setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和固定出口" /> + setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和网络出口" /> - {!currentConfirmation ? 保存有效确认后才可加入队列。 : null} + {currentConfirmation ? null : 保存有效确认后才可加入队列。} {currentConfirmation ? 确认快照 v{currentConfirmation.version}账号版本 {currentConfirmation.account_version} · 草稿版本 {currentConfirmation.draft_version}环境:{currentConfirmation.browser_env_alias || '未记录'} · 出口:{currentConfirmation.network_exit_id || '未记录'} · 绑定版本:{currentConfirmation.binding_version || '未记录'} : null} @@ -160,7 +160,7 @@ export function DraftDetail() { busy ? undefined : closeDialog()} aria-labelledby="confirm-draft-title" slotProps={{ transition: { onExited: () => confirmTrigger.current?.focus() } }}> 确认草稿版本 - 将保存账号版本 {draft.account.version}、草稿版本 {draft.version}、运行环境 {binding?.alias} 与固定出口 {binding?.network_exit_id} 的只读确认快照。版本变化后必须重新确认。 + 将保存账号版本 {draft.account.version}、草稿版本 {draft.version}、运行环境 {binding?.alias} 与网络出口 {binding?.network_exit_id || '当前机器直连'} 的只读确认快照。版本变化后必须重新确认。 diff --git a/web/src/DraftDetail.test.jsx b/web/src/DraftDetail.test.jsx index 4c319d4..04e09db 100644 --- a/web/src/DraftDetail.test.jsx +++ b/web/src/DraftDetail.test.jsx @@ -46,6 +46,16 @@ describe('DraftDetail', () => { expect(screen.getByRole('button', { name: '确认当前快照' })).toBe(document.activeElement) }) + it('allows confirming a direct-machine environment', async () => { + const direct = { ...binding, network_exit_id: '', network_exit_health: 'unchecked' } + const dataProvider = provider(draft, { getList: vi.fn().mockResolvedValue({ data: [direct], total: 1 }) }) + renderDraft(dataProvider) + + fireEvent.click(await screen.findByRole('checkbox', { name: /我已核对当前账号/ })) + expect(screen.getByText(/网络出口:当前机器直连/)).toBeTruthy() + expect(screen.getByRole('button', { name: '确认当前快照' }).disabled).toBe(false) + }) + it('submits only once while a repeated enqueue click is in flight', async () => { let finish const pending = new Promise(resolve => { finish = resolve })