feat: support direct network exit for runtimes

This commit is contained in:
2026-09-01 19:12:48 +08:00
parent 5674cfe8c3
commit 3729ce910f
15 changed files with 259 additions and 144 deletions
+65 -48
View File
@@ -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
}
+40 -20
View File
@@ -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)
}
}
+1 -3
View File
@@ -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"
+1 -1
View File
@@ -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 {
+23 -15
View File
@@ -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
+44
View File
@@ -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{}
+4 -4
View File
@@ -94,11 +94,11 @@ docker compose logs --tail=200 creator-hub docker-gateway postgres
页面路径如下:
1. 登录后在「网关管理」注册 http://docker-gateway:8081,令牌必须等于 GATEWAY_TOKEN。
1. 登录后在「网关管理」注册 <http://docker-gateway:8081,令牌必须等于> 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.sockDOCKER_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 网络内应为 <http://docker-gateway:8081;重新注册时令牌必须完全一致> |
| 出口一直是 unchecked/unhealthy | 出口协议、主机、端口;控制面容器到代理的连通性;last_check_reason | 先用无认证代理完成最小验证;有认证时只提供已配置的凭据引用,不把认证值放到请求、日志或文档 |
| 创建环境时报 image_unavailable 或拉取超时 | image_ref 格式、镜像架构、Docker daemon 的 registry 登录和网络 | 版本表中的镜像必须可被 Docker daemon 拉取;缺失镜像会由网关按引用拉取,最长约 10 分钟 |
| 恢复/入队返回 503 | readiness、GET /api/browsers/<alias>、GET /api/network-exits/<id> | binding_missing、network_exit_unhealthy、runtime_missing 表示固定资源未就绪;先修复出口并启动原环境,不要换出口重试 |
+7 -6
View File
@@ -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)
+24 -24
View File
@@ -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()
)
+3 -3
View File
@@ -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() {
<Alert severity="warning" icon={<PauseCircleOutlined />} sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold恢复账号只恢复账号可用性不会自动恢复既有 hold</Alert>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'repeat(2, minmax(0, 1fr))' }, gap: 2.5 }}>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">账号状态</Typography><AccountState account={account} binding={binding} bindingError={browsersError} /><Typography>授权{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}{account.authorization_kind}</Typography><Typography>运行{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}</Typography><Typography sx={wrapAnywhere}>凭据引用{account.credential_reference?.id} · {account.credential_reference?.provider}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}><Button variant="outlined" color="warning" disabled={busy || account.runtime_status === 'paused'} onClick={() => act('pause')}>暂停账号</Button><Button variant="contained" disabled={busy || !readiness.canResume} onClick={() => act('resume')}>恢复账号</Button></Stack></Stack></Paper>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境固定出口与 readiness 状态未知重试成功后再执行依赖资源状态的操作</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境{binding.name}{binding.alias}</Typography><Typography sx={wrapAnywhere}>固定出口{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}</Typography><Typography>绑定版本{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境与固定出口因此不能恢复或排队</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境与出口</Button></>}</Stack></Paper>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境与网络出口状态未知重试成功后再执行依赖资源状态的操作</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境{binding.name}{binding.alias}</Typography><Typography sx={wrapAnywhere}>网络出口{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}</Typography><Typography>绑定版本{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境因此不能恢复或排队</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境</Button></>}</Stack></Paper>
</Box>
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
<Typography variant="h6">文本草稿</Typography>
+3 -2
View File
@@ -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(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
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: '重试环境状态' }))
+6 -11
View File
@@ -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 }
</TextField>
</Stack>
<Stack spacing={1}>
{label('env-exit', '网络出口', true)}
<TextField id="env-exit" select required slotProps={{ htmlInput: { 'aria-label': '网络出口' } }} value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)} disabled={healthyExits.length === 0} helperText={healthyExits.length === 0 ? '请先检查并启用健康出口' : ' '}>
{label('env-exit', '网络出口')}
<TextField id="env-exit" select slotProps={{ htmlInput: { 'aria-label': '网络出口' } }} value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)} helperText="留空时使用当前机器网络出口直连">
<MenuItem value="">当前机器直连</MenuItem>
{healthyExits.map(exit => <MenuItem key={exit.id} value={exit.id}>{exit.id} · {exit.protocol}://{exit.host}:{exit.port}</MenuItem>)}
</TextField>
</Stack>
@@ -300,7 +295,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
<TableRow key={runtime.alias} sx={{ '&:last-child td': { borderBottom: 0 } }}>
<TableCell><Typography fontWeight={650}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · seed {runtime.fingerprint?.seed}</Typography></TableCell>
<TableCell><Status state={runtime.state} /></TableCell>
<TableCell><Typography variant="body2">{runtime.account_id || '未绑定账号'}</Typography><Typography variant="caption" color="text.secondary">{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version}</Typography><BindingReadiness runtime={runtime} /></TableCell>
<TableCell><Typography variant="body2">{runtime.account_id || '未绑定账号'}</Typography><Typography variant="caption" color="text.secondary">{runtime.network_exit_id || '当前机器直连'} · {runtime.image_version}</Typography><BindingReadiness runtime={runtime} /></TableCell>
<TableCell>{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <Typography variant="body2" color="text.secondary"></Typography>}</TableCell>
<TableCell><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></TableCell>
</TableRow>
@@ -312,7 +307,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
}
function RuntimeCards({ runtimes, busy, onAction }) {
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} /><Typography variant="body2">账号{runtime.account_id || '未绑定'} · 固定出口{runtime.network_exit_id || '未绑定'}</Typography><BindingReadiness runtime={runtime} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} /><Typography variant="body2">账号{runtime.account_id || '未绑定'} · 网络出口{runtime.network_exit_id || '当前机器直连'}</Typography><BindingReadiness runtime={runtime} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
}
export function BrowserList() {
+21
View File
@@ -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(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
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(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
+7 -7
View File
@@ -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() {
</Box>
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>环境不可用当前运行环境与固定出口状态未知不能确认或排队</Alert> : null}
{!snapshotCurrent ? <Alert severity="warning" action={<Button component={RouterLink} to={`/drafts/${versions[0]?.id}`}>打开最新版本</Button>} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本请刷新到版本 {latestVersion} 后重新核对</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>环境不可用当前运行环境与网络出口状态未知不能确认或排队</Alert> : null}
{snapshotCurrent ? null : <Alert severity="warning" action={<Button component={RouterLink} to={`/drafts/${versions[0]?.id}`}>打开最新版本</Button>} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本请刷新到版本 {latestVersion} 后重新核对</Alert>}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'minmax(0, 1.5fr) minmax(0, 1fr)' }, gap: 2.5 }}>
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, minWidth: 0 }}>
@@ -129,7 +129,7 @@ export function DraftDetail() {
<Typography>账号版本{draft.account.version}</Typography>
<Typography>草稿版本{draft.version}</Typography>
<Typography sx={wrapAnywhere}>运行环境{browsersError ? '状态未知' : (binding?.name || '未绑定')}</Typography>
<Typography sx={wrapAnywhere}>固定出口{browsersError ? '状态未知' : (binding?.network_exit_id || '未绑定')}</Typography>
<Typography sx={wrapAnywhere}>网络出口{browsersError ? '状态未知' : (binding?.network_exit_id || '当前机器直连')}</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', color: readiness.ready ? 'success.main' : 'warning.main' }}>{readiness.ready ? <CheckCircleOutlined /> : <ReportProblemOutlined />}<Typography fontWeight={650}>{readiness.label}</Typography></Stack>
</Stack>
</Paper>
@@ -144,12 +144,12 @@ export function DraftDetail() {
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
<Typography variant="h6">显式核对与入队</Typography>
<FormControlLabel sx={{ mt: 1.5, alignItems: 'flex-start' }} control={<Checkbox checked={checked} onChange={event => setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和固定出口" />
<FormControlLabel sx={{ mt: 1.5, alignItems: 'flex-start' }} control={<Checkbox checked={checked} onChange={event => setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和网络出口" />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2 }}>
<Button ref={confirmTrigger} variant="outlined" disabled={!checked || !canConfirm || busy} onClick={openDialog}>确认当前快照</Button>
<Button variant="contained" disabled={!canEnqueue || busy} onClick={enqueue}>{busy ? '处理中…' : '加入队列'}</Button>
</Stack>
{!currentConfirmation ? <Typography color="text.secondary" sx={{ mt: 1.5 }}>保存有效确认后才可加入队列</Typography> : null}
{currentConfirmation ? null : <Typography color="text.secondary" sx={{ mt: 1.5 }}>保存有效确认后才可加入队列</Typography>}
{currentConfirmation ? <Paper variant="outlined" sx={{ p: 2, mt: 2.5, minWidth: 0 }}><Typography fontWeight={700}>确认快照 v{currentConfirmation.version}</Typography><Typography variant="body2">账号版本 {currentConfirmation.account_version} · 草稿版本 {currentConfirmation.draft_version}</Typography><Typography variant="body2" sx={wrapAnywhere}>环境{currentConfirmation.browser_env_alias || '未记录'} · 出口{currentConfirmation.network_exit_id || '未记录'} · 绑定版本{currentConfirmation.binding_version || '未记录'}</Typography></Paper> : null}
</Paper>
@@ -160,7 +160,7 @@ export function DraftDetail() {
<Dialog open={dialogOpen} disableRestoreFocus onClose={() => busy ? undefined : closeDialog()} aria-labelledby="confirm-draft-title" slotProps={{ transition: { onExited: () => confirmTrigger.current?.focus() } }}>
<DialogTitle id="confirm-draft-title">确认草稿版本</DialogTitle>
<DialogContent><DialogContentText>将保存账号版本 {draft.account.version}草稿版本 {draft.version}运行环境 {binding?.alias} 固定出口 {binding?.network_exit_id} 的只读确认快照版本变化后必须重新确认</DialogContentText></DialogContent>
<DialogContent><DialogContentText>将保存账号版本 {draft.account.version}草稿版本 {draft.version}运行环境 {binding?.alias} 网络出口 {binding?.network_exit_id || '当前机器直连'} 的只读确认快照版本变化后必须重新确认</DialogContentText></DialogContent>
<DialogActions><Button autoFocus disabled={busy} onClick={closeDialog}>返回核对</Button><Button variant="contained" disabled={busy} onClick={confirm}>{busy ? '确认中…' : '保存确认'}</Button></DialogActions>
</Dialog>
</>
+10
View File
@@ -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 })